Compare commits
64
Commits
0b79269b2c
..
v0.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3bb1b264 | ||
|
|
80f4406ff2 | ||
|
|
ff94d0c124 | ||
|
|
0e20413641 | ||
|
|
1b0d2085bb | ||
|
|
89ddf8a9e7 | ||
|
|
89d2c65707 | ||
|
|
7c792c2a96 | ||
|
|
f2d47f1437 | ||
|
|
53a9ced6bf | ||
|
|
1074dc8678 | ||
|
|
8e6d0d799c | ||
|
|
8b53c0a3ab | ||
|
|
2648c0f8e2 | ||
|
|
39455ff18e | ||
|
|
1b24bc8ff2 | ||
|
|
0467101faf | ||
|
|
db0ba36610 | ||
|
|
7687204b43 | ||
|
|
e40039ceec | ||
|
|
291158a3ba | ||
|
|
fbebcf5138 | ||
|
|
069c754877 | ||
|
|
feea87557c | ||
|
|
8edfbfde44 | ||
|
|
6f62d19905 | ||
|
|
61b18cbefe | ||
|
|
1064ce954f | ||
|
|
eb5059574e | ||
|
|
a6befc8b6b | ||
|
|
4060bd53e1 | ||
|
|
a1286191d6 | ||
|
|
9b00135de4 | ||
|
|
06440d0365 | ||
|
|
c4851dceb7 | ||
|
|
ed6d86d5ee | ||
|
|
260e3b8d6b | ||
|
|
81ab0c5312 | ||
|
|
9b153844e9 | ||
|
|
e2ee427ea3 | ||
|
|
a10c9fc77e | ||
|
|
2247f6ecce | ||
|
|
fec53cef0d | ||
|
|
c81de3374f | ||
|
|
f091ac322b | ||
|
|
6e36a99c88 | ||
|
|
88b08de957 | ||
|
|
439adf3967 | ||
|
|
7aa24badf0 | ||
|
|
d1cdca3324 | ||
|
|
cef0251aba | ||
|
|
9363ccfd9d | ||
|
|
f4b86770d0 | ||
|
|
e80c1f6625 | ||
|
|
97e1f60e47 | ||
|
|
47ebffcf9b | ||
|
|
8950e2ae69 | ||
|
|
302bd85e62 | ||
|
|
0621504e1b | ||
|
|
56a27990d7 | ||
|
|
c7a7c7e5af | ||
|
|
58b4bd71ba | ||
|
|
63e2988ef1 | ||
|
|
4f4e462f98 |
+141
-24
@@ -1,31 +1,148 @@
|
||||
# Build output
|
||||
lib/
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Logs & caches
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
.vite/
|
||||
.vitest/
|
||||
coverage/
|
||||
.cache/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Local env
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp directory
|
||||
.temp
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# pnpm
|
||||
pnpm-debug.log*
|
||||
.pnpm-store
|
||||
|
||||
# Unrelated work that landed in this directory (not part of apes-sidebar)
|
||||
maxkb-integration/
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
lib/
|
||||
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# apes-sidebar build config
|
||||
# Allow node-pty (a transitive runtime dep of dsh-better-sidebar's terminal
|
||||
# panel) to run its native build script. Phase 0 never exercises the terminal
|
||||
# panel, but pnpm's ignored-builds gate hard-fails the preinstall dep check
|
||||
# otherwise, which blocks `pnpm build`.
|
||||
onlyBuiltDependencies=node-pty
|
||||
@@ -1,157 +1,486 @@
|
||||
# apes-sidebar — engineering conventions
|
||||
# dsh-better-sidebar 插件接入文档
|
||||
|
||||
A DSH (DeepSeek Harness) **sidebar plugin** that registers five information-layer
|
||||
tabs into the `dsh-better-sidebar` host: `apes:tasks`, `apes:agents`,
|
||||
`apes:apps`, `apes:messages`, `apes:profile`.
|
||||
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
|
||||
This repo is a **from-scratch v2.0.0 rewrite** of the legacy JS-bundle sidebar.
|
||||
The engineering template is `dsh-better-sidebar` (TS + tsdown + host/client
|
||||
split). If a convention here conflicts with `dsh-better-sidebar`, follow
|
||||
better-sidebar and file a note in `docs/`.
|
||||
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
|
||||
|
||||
## Stack
|
||||
---
|
||||
|
||||
- **Language**: TypeScript (strict, `noUncheckedIndexedAccess`, ESM, `.ts` extension imports)
|
||||
- **Build**: `tsdown` (rolldown). Dual output — `lib/index.js` + `lib/invariant.js`
|
||||
(host, ESM) and `lib/client.js` (client, self-contained bundle with a named
|
||||
`apply` export)
|
||||
- **Type check**: `tsc --noEmit -p tsconfig.json`
|
||||
- **Unit tests**: `vitest` (node environment, no DOM)
|
||||
- **Package manager**: `pnpm` (Node ≥ 22.13 required — `node:sqlite` + pnpm 11)
|
||||
- **Peer**: `dsh-better-sidebar` ≥ 0.18.0 (host-provided, never bundled)
|
||||
- **Dev-only types**: `@deepseek-ai/cordis` (types; host provides at runtime)
|
||||
## 1. 服务定位
|
||||
|
||||
## Commands
|
||||
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
|
||||
- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布)
|
||||
- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`)
|
||||
- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
|
||||
|
||||
```bash
|
||||
pnpm install # install deps (preinstall re-checks on build)
|
||||
pnpm typecheck # tsc --noEmit (strict)
|
||||
pnpm build # rm -rf lib && tsc (d.ts) && tsdown (bundles)
|
||||
pnpm test # vitest run (unit only; no e2e in Phase 0)
|
||||
pnpm build && pnpm test && pnpm typecheck # the full gate
|
||||
> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 消费插件的最小骨架
|
||||
|
||||
### 2.1 `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the **full gate** before pushing. All three must be green.
|
||||
- `dsh-better-sidebar` 必须声明为 **peerDependency**(不是 dependency,避免重复实例化)
|
||||
- 标记 `optional: true` 让你的插件在 better-sidebar 未安装时也能加载(注册代码会因为 `ctx.betterSidebar` 为 undefined 而跳过)
|
||||
|
||||
## Layout
|
||||
### 2.2 client half 入口
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # HOST entry: export name/inject/apply (namespace plugin shape)
|
||||
invariant.ts # invariant surface (assert, semver, peer guard) — dep-free
|
||||
context-types.ts # service face contracts (ApesContext = CordisContext & {...})
|
||||
client/
|
||||
index.tsx # CLIENT entry: named `apply(ctx)` registering apes:* tabs
|
||||
locales.ts # i18n (zh default, en fallback) + useApesLocale hook
|
||||
tests/
|
||||
smoke.spec.ts # host plugin shape + apply + invariant surface
|
||||
locales.spec.ts # i18n contract against a mock ApesLocaleService
|
||||
tsdown.config.ts # dual build config (host ESM + client CJS)
|
||||
vitest.config.ts
|
||||
cordis.patch.yml # plugin registration into the DSH profile
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// 注册一个 sidebar tab
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ ctx, scope, tab }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
|
||||
// 注册一个文件预览器
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。
|
||||
|
||||
1. **Namespace plugin export shape.** `src/index.ts` MUST export
|
||||
`{ name, inject, apply }` as named exports — **never** a `default` export.
|
||||
A stray `default` makes the cordis `Loader.unwrapExports` discard
|
||||
`name`/`inject`/`apply` and the plugin silently fails to mount.
|
||||
`tests/smoke.spec.ts` pins this.
|
||||
### 2.3 类型导入
|
||||
|
||||
2. **Host / client split is load-bearing.**
|
||||
- The **host** half (`lib/index.js`) runs in Node. It may use Node builtins.
|
||||
- The **client** half (`lib/client.js`) runs in the DSH browser shell. Its
|
||||
entry is a **named `export function apply(ctx: ApesClientContext)`** (not a
|
||||
default export); tsdown bundles it to a self-contained module whose export
|
||||
face is `{ apply, ... }`. The host wires it via `ctx.betterSidebar` /
|
||||
`ctx.effect` (read from `ctx`, not a separately-injected `hostApi`).
|
||||
React is external (host-provided). Never `import` a Node builtin into
|
||||
`src/client/**`.
|
||||
- `src/invariant.ts` is reachable from BOTH halves — it must stay
|
||||
dependency-free (no Node builtins, no @deepseek-ai imports).
|
||||
```ts
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
3. **`dsh-better-sidebar` is a peerDependency, not a bundled dep.** The tsdown
|
||||
config lists it in `deps.neverBundle`. If a build inlines it, the client
|
||||
bundle ships a second copy of the sidebar registry — tabs register into a
|
||||
dead instance and never appear.
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
|
||||
4. **`@deepseek-ai/cordis` is devDependency (types only).** The host provides
|
||||
the real cordis context at runtime. Do NOT add cordis to `dependencies` or
|
||||
to `deps.neverBundle` — that would force the client to resolve it.
|
||||
---
|
||||
|
||||
5. **Tab IDs are `apes:*`.** Never `wb:*` (legacy prefix) or any better-sidebar
|
||||
internal id. The five Phase 1 tabs: `apes:tasks`, `apes:agents`,
|
||||
`apes:apps`, `apes:messages`, `apes:profile`.
|
||||
## 3. Tab 注册 API
|
||||
|
||||
6. **Scope lock.** This plugin is the **information layer only**:
|
||||
tasks / agents / apps / messages / profile. Do NOT add file/terminal/browser
|
||||
panels — those belong to `dsh-better-sidebar` itself. Do NOT add team/room
|
||||
features — those belong to `dsh-background-agents`.
|
||||
### 3.1 `TabDescriptor` 完整字段
|
||||
|
||||
7. **Bilingual i18n.** Every user-facing string goes through `t(key)` in
|
||||
`src/client/locales.ts` with BOTH a `zh` and an `en` entry. No raw strings in
|
||||
JSX.
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
|
||||
id: string
|
||||
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
|
||||
title: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
8. **pnpm build-script approval.** `node-pty` (a transitive runtime dep of
|
||||
better-sidebar's terminal panel) needs its native build approved once:
|
||||
`pnpm approve-builds` (pick `node-pty`, `y`). Without this, `pnpm build`'s
|
||||
preinstall dep-check hard-fails. It is approved in the committed
|
||||
`pnpm-lock.yaml` state — don't reset it.
|
||||
### 3.2 `TabComponentProps`
|
||||
|
||||
## Testing conventions
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? }
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
- Unit tests run in a **node** vitest environment. No DOM, no jsdom (the
|
||||
client React components are not rendered in Phase 0 — a full cordis scratch
|
||||
mount smoke lands in Phase 1).
|
||||
- The host `apply()` is tested against a **minimal fake context** (see
|
||||
`tests/smoke.spec.ts`) — never against a live DSH shell in CI.
|
||||
- The locale contract is tested against a **mock `ApesLocaleService`** (see
|
||||
`tests/locales.spec.ts`).
|
||||
- A test that needs the real DSH host is a **Phase 1+** concern (cordis
|
||||
scratch profile); do not add such tests to Phase 0.
|
||||
### 3.3 注册示例
|
||||
|
||||
## Gotchas (learned, don't rediscover)
|
||||
**最简 tab**(单实例、+ 菜单可见):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
- `tsdown` 0.22 deprecated `external`/`noExternal` in favour of
|
||||
`deps.neverBundle`/`deps.alwaysBundle`. **`external` and `deps.neverBundle`
|
||||
cannot coexist** — using both throws at build. Use only the `deps` API.
|
||||
- `allowImportingTsExtensions` (for `.ts`-extension imports) is set in
|
||||
`tsconfig.json`. `tsconfig.build.json` inherits it but emits `d.ts`, which is
|
||||
compatible (tsdown handles the strip). If `tsc` build complains, the
|
||||
declaration emit needs `emitDeclarationOnly` + `noEmit: false` — that is the
|
||||
current `tsconfig.build.json` setup.
|
||||
- `node-pty` build approval is **per-machine state** in `pnpm-lock.yaml` + the
|
||||
pnpm store. A fresh `pnpm install` on a clean checkout will re-prompt.
|
||||
- The workspace path contains spaces and Chinese characters
|
||||
(`文稿 - figmarLite的MacBook Pro/...`) — always quote it in shell commands.
|
||||
- **`lib/client.js` is a DSH browser `__ModuleLoader__` bundle, NOT plain
|
||||
CJS/ESM.** Line 1 is `window.__ModuleLoader__.load({...})` — it is consumed
|
||||
by the DSH browser shell's module loader and **cannot be `require()`d in
|
||||
Node** (top-level `window` reference throws `window is not defined`). This is
|
||||
why the cordis scratch mount smoke cannot run in the unit gate: the client
|
||||
artifact is browser-only by construction, not just because the `dsh` CLI is
|
||||
absent. Unit specs test the **source** `src/client/index.tsx` (`apply`
|
||||
contract), never the built bundle.
|
||||
**多实例 tab**(每次新开、带自定义 id):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:doc',
|
||||
title: 'Doc',
|
||||
icon: <DocIcon />,
|
||||
order: 60,
|
||||
// 不设 dedupeKey:每次 openTab 都新开
|
||||
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
// 外部触发打开:
|
||||
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
|
||||
```
|
||||
|
||||
## Phase status
|
||||
**条件可见**(仅 git 仓库时显示):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:commits',
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
|
||||
surface, build gate green, unit smoke green.
|
||||
- **Phase 1 (done)**: real tab registration for the five `apes:*` panels
|
||||
against a mock host registry (type-only real `TabDescriptor` /
|
||||
`TabComponentProps` / `Context` from `dsh-better-sidebar`), i18n for all
|
||||
five views, `ctx.effect`-wrapped registration with HMR-safe disposer, and
|
||||
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
|
||||
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
|
||||
to Phase 2 — it needs a live host and is outside the unit gate.
|
||||
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
|
||||
subscription pause) + per-tab state landed for the information panels.
|
||||
`apes:tasks` / `apes:agents` show live count pills (running / subagent);
|
||||
`apes:tasks` persists a `showInactive` preference through the settings
|
||||
namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount
|
||||
smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred**
|
||||
— better-sidebar 0.18.0 exposes no workspace-apps / message-stream /
|
||||
model-provider host feeds for those three panels, and a live scratch mount
|
||||
needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention
|
||||
deliberately avoids. See docs/ for the deferral rationale.
|
||||
### 3.4 内置 tab(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
|
||||
---
|
||||
|
||||
## 4. FileViewer 注册 API
|
||||
|
||||
### 4.1 `FileViewerDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
priority?: number
|
||||
/** 字节获取策略 */
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `fetchStrategy` 对照
|
||||
|
||||
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
### 4.4 匹配算法
|
||||
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
|
||||
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
|
||||
**CSV 预览器**(自定义加载 + 渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const text = await fetchText(scope, path)
|
||||
return parseCsv(text)
|
||||
},
|
||||
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**覆盖内置 image viewer**(如想用自定义的 SVG 优化渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:svg-pro',
|
||||
exts: ['svg'],
|
||||
priority: 10, // 高于内置 image 的 0
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:magic-parquet',
|
||||
exts: [], // catch-all,但 priority 高 + detect 精确命中
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head.length >= 4
|
||||
&& head[0] === 0x50 && head[1] === 0x41
|
||||
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
|
||||
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
|
||||
component: ({ customData }) => <ParquetTable data={customData} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 服务方法完整清单
|
||||
|
||||
```ts
|
||||
interface BetterSidebarService {
|
||||
/** 注册 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生命周期与 HMR
|
||||
|
||||
- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
|
||||
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。
|
||||
- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。
|
||||
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。
|
||||
|
||||
---
|
||||
|
||||
## 7. 平台约束与陷阱
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整最小示例
|
||||
|
||||
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
|
||||
|
||||
**`my-plugin/package.json`**:
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`my-plugin/src/client/index.tsx`**:
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// Database tab
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:db',
|
||||
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
|
||||
})
|
||||
)
|
||||
|
||||
// CSV viewer
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const res = await fetch('/sidebar/api/fs.read', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: scope.sessionId, path }),
|
||||
})
|
||||
const { value } = await res.json()
|
||||
return parseCsv(value.content)
|
||||
},
|
||||
component: ({ customData, path }) =>
|
||||
createElement(CsvGrid, { rows: customData as string[][], path }),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
|
||||
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
|
||||
function parseCsv(text: string): string[][] { /* ... */ }
|
||||
```
|
||||
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 dsh-external
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,130 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
|
||||
|
||||
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 按钮引用到输入框、右键复制相对/绝对路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限),Tab 保活重连并回放转录;可选为模型注入 8 个 `terminal_*` 工具(agent 终端自动同步为 tab)
|
||||
- **🌿 Git 面板**:真 diff 显示、VSCode 式 diff tab(首次自动下分栏)、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 Subagent 页面**:主会话完整 agent 拓扑(分层树)、点击直达执行记录、运行中节点实时工具调用轮询、检测到新子代理自动展开
|
||||
- **🧩 分栏工作台**:拖 Tab 到边缘拆分/中间合并、分隔线调比例、宽度拖拽、折叠 + 全屏
|
||||
- **🔁 会话隔离**:布局/分栏/Tab 按会话持久化(localStorage),陈旧状态自动净化;拦截对话"产出文件"行改为在侧边栏打开
|
||||
- **🎨 原生观感**:完全复用 DSH 主题 token 与官方组件,明暗自适应
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表实时渲染功能清单——小卡片网格(高亮 = 启用,勾选徽标钉在最右端),每项可独立开/关(tab 从 `+` 菜单消失、viewer 预览落到下一个匹配);子代理/终端等二级设置经卡片齿轮按钮在原生弹窗中编辑;另有新会话默认打开、默认宽度占比
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(详见 [AGENTS.md](./AGENTS.md))
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🚀 安装(推荐:官方 profile)
|
||||
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
|
||||
```text
|
||||
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
|
||||
2. 注册到 web profile:
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
3. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
```
|
||||
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行,与面板的渲染方式无关。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
|
||||
### 更新
|
||||
|
||||
```text
|
||||
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
(401/404 处理同上)
|
||||
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
|
||||
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
|
||||
```
|
||||
|
||||
`link:` 引用下 `pnpm install` 无需重复执行(仓库已装即可)。
|
||||
|
||||
## 📦 通过 plugin-registry 安装(可选)
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**与官方 profile 安装二选一**——同时启用会双挂载(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(只含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`(同上三步)。切换通道前先移除另一通道的挂载(profile 的 link: 依赖与挂载行,或 `dsh registry uninstall`)。
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 6 tab + 8 viewer 也走同一服务,吃自己的狗粮):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
运行期不依赖 npm / checkout:`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供。
|
||||
|
||||
**规范符合性**:插件按 DSH 官方插件规范组织(参考 turtle-ui),无 default 导出(`name/inject/Config/apply`);双 client bundle(官方通道 id = 包名 / registry 通道 id = 清单 id,`tests/manifest-consistency.spec.ts` 守卫);已知偏差:侧边栏面板经 portal 挂载(`'root'` slot 由 ui-layout 独占,外部插件无整面板 slot)。
|
||||
|
||||
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + turnTail 拦截;client 状态按会话持久化 localStorage;client bundle externals 仅平台模块表词条,xterm/CodeMirror 等内联。
|
||||
|
||||
## 🔐 安全
|
||||
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致)
|
||||
- `fs.write` 原子写入;媒体路由仅限会话 cwd 内文件
|
||||
- git 操作仅调用 git CLI,绝不设置 git 身份
|
||||
|
||||
## ⚠️ 已知限制
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开);切换 Tab / 会话 / 刷新不会
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制)
|
||||
- Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
@@ -1,13 +0,0 @@
|
||||
# apes-sidebar bundle patch: inserts the host plugin row into the profile
|
||||
# roster. The client half (sidebar tabs: Tasks / Agents / Messages / Apps /
|
||||
# Profile) loads through the dsh.client.inject declaration in package.json;
|
||||
# this patch mounts the Node-side host entry only.
|
||||
#
|
||||
# Double-mount guard: if another *enabled* entry already mounts the same
|
||||
# package (e.g. via an aggregate bundle), this row backs off.
|
||||
- insert:
|
||||
- id: apes-sidebar
|
||||
name: apes-sidebar
|
||||
disabled: !!js >
|
||||
[...ctx.loader.entries()].some((e) =>
|
||||
e.options.name === 'apes-sidebar' && e.options.id !== 'apes-sidebar' && !e.disabled)
|
||||
@@ -1,362 +0,0 @@
|
||||
# apes-sidebar 重写方案(v2)— 冲突检查通过,从零开始
|
||||
|
||||
> 目标:重写现有 `apes-sidebar`(v0.4.0,单文件手写 JS client bundle),
|
||||
> **代码规范/工程方案学习 [DSH-better-sidebar v0.18.0](https://github.com/omdsh-dev/DSH-better-sidebar)**,
|
||||
> **需求功能/交互能力学习 [tutti-os/tutti](https://github.com/tutti-os/tutti)**。
|
||||
>
|
||||
> 分析基线:DSH-better-sidebar `v0.18.0`(浅克隆 /tmp/DSH-better-sidebar)· Tutti(本地 /Users/figmarlite/Desktop/…/APES/tutti)
|
||||
> 生成时间:2026-09-03 · 用途:重构开工前对齐,供评审与拆单。
|
||||
|
||||
## 0. 冲突检查结论(2026-09-03)
|
||||
|
||||
**目标自洽,不冲突,无需推翻重规划。** 两个参考源互补:
|
||||
- better-sidebar v0.18.0 = 「怎么建」(工程规范/构建/测试/i18n/token/服务契约)
|
||||
- tutti-os/tutti = 「做什么」(面板模型/角标/agent 可编程驱动)
|
||||
|
||||
三处主动调整(借"从零开始"授权做干净化):
|
||||
1. **Tab id 换前缀**:旧 `wb:*` → 新 `apes:*`(apes:tasks / apes:agents / apes:apps / apes:messages / apes:profile),不留 `wb` 历史包袱。
|
||||
2. **定位收敛**:只做 **tasks/agents/apps/messages/profile** 五面板信息层;files/terminal/browser/editor 由 better-sidebar 宿主提供,**不重复造轮子**(复用 `openTab/openFile` + 宿主内置 tab 即可)。
|
||||
3. **放弃团队房间**:tutti 的 rooms/team 依赖 `dsh-background-agents` 0.7.x,与 DSH 基座 loader id 冲突会崩(历史教训),v2 不做。
|
||||
|
||||
唯一硬约束:peer 钉 `@deepseek-ai/cordis ^4.0.2` + `dsh-better-sidebar ^0.18.0`(better-sidebar 发 npm `next` dist-tag,随 DSH rc.x 漂移;升级前必须跑挂载冒烟)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状诊断(旧版 v0.4.0 的痛点)
|
||||
|
||||
### 1.1 现状结构
|
||||
|
||||
```
|
||||
figmar/apes-sidebar (v0.4.0)
|
||||
├── package.json # peer: cordis ^4.0.1 / better-sidebar ^0.16.0 / react 18
|
||||
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫)
|
||||
├── lib/index.js # host 最小入口(~1KB,只打日志)
|
||||
└── lib/client.js # 45KB 单文件,window.__ModuleLoader__.load 手写 bundle
|
||||
# 纯 JS + React.createElement,无 JSX/TS/构建/测试
|
||||
```
|
||||
|
||||
注册 4 个 Tab(全部基于 better-sidebar `ctx.betterSidebar.registerTab`):
|
||||
|
||||
| Tab id | 作用 | 参照 |
|
||||
|---|---|---|
|
||||
| `wb:tasks` | 工作区+时间分组会话列表、搜索、运行中徽标、运行中任务区 | WorkBuddy |
|
||||
| `wb:agents` | 子代理/后台 agent 列表、状态、一键跳转 | Tutti multi-agent |
|
||||
| `wb:apps` | 应用中心(Design/PPT/Video 网格) | Tutti App Center |
|
||||
| `wb:profile` | Provider/模型信息 + 设置/帮助入口 | WorkBuddy 底部 |
|
||||
|
||||
配置:better-sidebar 设置页 `pluginSettings['apes-sidebar']`(`enableTasks`/`enableAgents`/`enableApps`/`enableProfile`/`displayName`/`showTime`/`showUngrouped`/`appsList`)。
|
||||
|
||||
### 1.2 主要痛点
|
||||
|
||||
1. **单文件 45KB 手写 JS bundle**:无类型、无模块拆分、无构建产物管理;
|
||||
CSS 以字符串内联注入(`APPS_CSS` 拼接),每次改样式要手工编辑字符串。
|
||||
2. **零测试**:无 spec,无 CI 挂载冒烟——任何 API 漂移(如 DSH rc 升级)静默崩。
|
||||
3. **i18n 手写**:本地 zh/en 两字典,不跟随宿主 `ctx.locale`,第三方语言直接缺。
|
||||
4. **皮肤脆弱**:CSS 硬编码 `#fff` 渐变等(`card-icon` 用 `#3370ff→#7c3aed`),不消费
|
||||
`--dsw-alias-*` token 链,深色/浅色/品牌换肤会破。
|
||||
5. **状态模型简单**:轮询/快照读取 sessions/workspaces,无订阅模型、无 memo、无懒加载。
|
||||
6. **无 host 能力**:只有日志入口。需要 host 路由(读会话日志、子代理拓扑、job 回放)
|
||||
时只能绕 DSH 网关或放弃。
|
||||
7. **版本基线落后**:peer 只到 better-sidebar ^0.16.0;而 better-sidebar v0.18.0
|
||||
已毕业正式版、只支持 DSH 0.1.2-rc.1+(协议、RPC 方言、client store 均大变)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 参考源 A:DSH-better-sidebar v0.18.0 工程规范(「怎么建」)
|
||||
|
||||
### 2.1 包结构(published npm package)
|
||||
|
||||
- `name: dsh-better-sidebar` / `version: 0.18.0` / `type: module` / `pnpm@11.8.0`
|
||||
- `main: lib/index.js`;exports 子路径:`.` / `./invariant` / `./client` / `./client/service` / `./client/api` / `./src/*` / `./package.json`
|
||||
- `dsh.bundle.patch: ./cordis.patch.yml`(bundle 激活的**硬性资格**)
|
||||
- `dsh.client.inject`: `[locale, ui-slots, ui-conversation, modules]`,`platform: web`
|
||||
- `dsh.plugin.json`:市场 manifest 形态(id/version/main/engines.dsh/contributes/client.main)
|
||||
- `files`: lib + src + cordis.patch.yml + scripts + README
|
||||
|
||||
### 2.2 构建与质量
|
||||
|
||||
- `build`: `tsc -p tsconfig.build.json && tsdown`(类型声明 + JS 产物)
|
||||
- `typecheck` / `test`(vitest) / `lint:ts`(oxlint) / `playwright`(e2e)
|
||||
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)独立 bundle `lib/client-<name>.js`,
|
||||
经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`);
|
||||
核心 bundle 禁止静态 import chunks。
|
||||
- **CI 挂载冒烟**(`plugin-mount` job):`pnpm pack` → scratch profile 真实安装 →
|
||||
`dsh web`(keyless)→ Playwright 断言 `[data-dsh-better-sidebar]` 挂载、无 pageerror、
|
||||
+ 菜单逐 tab 打开、editor chunk 强制加载。本地:`pnpm build && pnpm pack && pnpm test:mount`。
|
||||
|
||||
### 2.3 仓库硬约束(AGENTS.md)
|
||||
|
||||
- 禁止修改 DSH 源码(零写入 `~/.dsh/source/current`);改动必须 PR(`feat/*`/`fix/*`)。
|
||||
- 挂载只走 `cordis.patch.yml` + profile;**不反向侵入 DSH**。
|
||||
- 市场约束:`dependencies/peerDependencies/optionalDependencies` **一律不得出现 `cordis`**;
|
||||
`scripts` 不得含 `preinstall/install/postinstall/prepare`(`tests/market-manifest.spec.ts` 守护)。
|
||||
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 jobs.output 事件回放),不改 DSH。
|
||||
|
||||
### 2.4 架构:host half + client half
|
||||
|
||||
**host half(Node 侧,`src/index.ts` ~1300 行)**
|
||||
- 导出 `export const name = 'dsh-better-sidebar'`、`inject = ['webServer','sessions','webRuntime','tools']`、`export function apply(ctx)`。
|
||||
- 插件自有路由全走同源 fence:`/sidebar/api/*`(JSON:fs.tree/search/read/write、git.*、session.cwd、
|
||||
changes.ops…)、`/sidebar/file`(媒体)、`/sidebar/html`(预览)、`/sidebar/bundle`(懒加载)、
|
||||
`/sidebar/ws/*`(终端 WebSocket upgrade)。
|
||||
- **信任围栏** `trust-fence.ts`:与 /api 网关同源(Host-header loopback / webRuntime trustedHosts),
|
||||
逐请求读取;**路径安全** `path-security.ts`:会话 cwd 内解析、workspaceFence 守卫、写路径原子写(tmp+rename)。
|
||||
- 会话隔离:所有请求带 sessionId,权威 cwd 来自 session store(header → client cwd → persistence → process.cwd 四级回退)。
|
||||
- PTY 管理:`pty-manager.ts` + `agent-pty.ts`(agent 终端注册表、resize gate、按会话配额)。
|
||||
- 工具注册:`registerTools`(agent 可调用)、`agent-opens.ts`(agent 主动开 tab 的 registry + open tool)。
|
||||
|
||||
**client half(浏览器侧,`src/client/index.tsx` ~446 行)**
|
||||
- `inject = ['slots','sessions','locale','modules','connection']`
|
||||
- `attachLocale(ctx.locale)` + `ctx.effect` 注册词典(zh/en 为主,第三语言走 better-locale 可选覆盖)。
|
||||
- `createSidebarStore()` 单例 per activation → `ctx.provide('betterSidebar', service)`。
|
||||
- `ctx.effect(() => registerBuiltins(ctx, service))`:内置 7 tab + 6 viewer **自己吃自己的狗粮**。
|
||||
- 挂载:`document.body` 追加 host div → `createRoot` 渲染 `RenderBoundary > Sidebar`;
|
||||
bodyObserver 防宿主移除;几何自检防页面 transform 漂移(degraded viewport sync)。
|
||||
- 拦截层:turn-tail 拦截、open-path 拦截(`remote.session.openWorkspacePath` 遮蔽)、外链拦截
|
||||
(urlTarget 认领,默认 browser tab)、**IME 守卫**(composition 键防第三方 UI 抢键)。
|
||||
- 设置面:`ctx.slots.inject('settings.section', …)` 声明「Side card」设置分区。
|
||||
|
||||
### 2.5 服务契约(消费插件 API,也是 apes-sidebar 重写后要面向的基座)
|
||||
|
||||
`ctx.betterSidebar`(client-only):
|
||||
|
||||
```ts
|
||||
registerTab(descriptor: TabDescriptor): () => void // 返回 disposer,HMR-safe
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
getTabs() / getFileViewers() / getTab(id)
|
||||
isTabEnabled(id) / isViewerEnabled(id)
|
||||
matchFileViewer(path, head?)
|
||||
openTab(seed: OpenTabSeed, scope?) / closeTab(tabId, scope?)
|
||||
openFile(scope, path, title?)
|
||||
updateTab / activateTab
|
||||
subscribe(listener) / getSnapshot() / subscribeState(listener)
|
||||
```
|
||||
|
||||
`TabDescriptor` 关键字段(v0.18.0 全量):
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
order?: number // + 菜单排序,默认 100
|
||||
hidden?: boolean
|
||||
available?: (ctx, scope, state) => boolean // + 菜单禁用谓词
|
||||
single?: boolean // 单实例(dedupeKey: () => id 语法糖)
|
||||
dedupeKey?: (tab) => string | undefined
|
||||
createTab?: (state) => { tab, patch? } | null // 自定义 mint tab id
|
||||
urlTarget?: (url: URL) => boolean // 外链认领
|
||||
settings?: SidebarSettingsDeclaration // toggles / pluginToggles / render
|
||||
badge?: (ctx, scope, state) => string | number | null | undefined // Tab 角标
|
||||
onOpen? / onActivate? / onClose? // 生命周期(仅 service 路径触发)
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
声明式设置:`'switch' | 'text' | 'number' | 'select'`;select 支持 `multi` 与图标选项卡。
|
||||
`TabComponentProps = { ctx, store, scope, tab, visible, expanded?, revealed?, onToggleDir?/onOpenFile?/… }`。
|
||||
|
||||
**接入规范(外部插件视角)**:
|
||||
- peerDependencies: `@deepseek-ai/cordis` + `dsh-better-sidebar`(optional);
|
||||
type-only import 触发 `declare module '@deepseek-ai/cordis'` 类型合并;
|
||||
- 纯浏览器插件走 `import type {} from 'dsh-better-sidebar/client/service'`(零 Node 依赖图);
|
||||
- client half `inject: ['betterSidebar', …]`,`ctx.effect(() => ctx.betterSidebar.registerTab(...))`;
|
||||
- 宿主 half 无此服务——需要读状态走 better-sidebar 自己的 HTTP/WS 路由。
|
||||
|
||||
### 2.6 i18n / 皮肤 / 无障碍规范
|
||||
|
||||
- i18n:词典注册到 DSH `ctx.locale`(LOCALE_NS);新增 zh key 必须同步 ja(否则 ja 回退 en);
|
||||
渲染 MarkdownText 必须经 `markdownTextProps()`(labels 嵌套契约)。
|
||||
- 皮肤:**视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌**,无硬编码颜色;
|
||||
硬编码 hex 只能做 token 链末端 fallback(`color:var(--x,#fff)`),并有 `tests/theme.spec.ts` 守护。
|
||||
- a11y:role=tablist/tab/separator、aria-selected/valuenow、focus-visible ring、IME guard。
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考源 B:Tutti 侧边栏能力(「做什么」)
|
||||
|
||||
### 3.1 面板模型(packages/agent/gui/workbench/tool-sidebar/model.ts)
|
||||
|
||||
```ts
|
||||
export const agentToolPanelIds = ['files','terminal','browser','tasks','apps','messages','side'] as const
|
||||
```
|
||||
|
||||
- 每面板有 label、可开多实例(resourceId 区分,如 browser tab 带 agent session id)。
|
||||
- 每面板默认/最小/最大宽度(files 最大不限、side 默认 440 最大 600、其余默认 720/最小 420)。
|
||||
- `mainContentMinWidth = 280`:主内容区最小宽度保护。
|
||||
|
||||
### 3.2 行为层(AgentToolSidebar.tsx + useAgentToolSidebarController)
|
||||
|
||||
- **多标签**:TabBar(role=tablist),每个 tab = panel + resourceId;关闭/激活/拖拽。
|
||||
- **可编程 handle**(AgentToolSidebarHandle,agent/宿主可驱动):
|
||||
`addPanel / ensurePanel / openPanel / closeTab / close / collapseForContainerConstraint`
|
||||
- **容器约束自动折叠**:`shouldAutoCollapseAgentToolSidebar({containerWidth, mainContentMinWidth, sidebarWidth})`
|
||||
→ 窗口变窄时 `collapseForContainerConstraint()`(不关 tab,只收起到 Dock)。
|
||||
- **宽度记忆 + 动画**:`onLayoutWidthChange`、resize handle(键盘可操作 role=separator)、
|
||||
width transition 260ms。
|
||||
- **懒挂载渲染**:`contentReadyTabIds` + `renderLoading`(未就绪 tab 显示 loading,就绪才挂)。
|
||||
- **空态 Picker**:`isEmptySidebar` → AgentToolSidebarPicker(从 + 菜单选面板)。
|
||||
- **展开态**:expand 按钮让活动面板撑满(expanded panel 概念)。
|
||||
|
||||
### 3.3 信息架构与角标(StandaloneAgentToolSidebar.tsx)
|
||||
|
||||
- `panels` 条件组装:`side` 面板仅在有 side conversation identity 时出现(`canAdd: false`)。
|
||||
- `quickActionPanels`: 有自动化 browser 时把 browser 提到 quick action;常驻 tasks/apps/messages。
|
||||
- `reminders`(角标计数):
|
||||
- `browser: automationBrowserCount`(运行中 automation browser tab 数)
|
||||
- `messages: messageCenterWorkingCount`(工作会话中 working agent 消费者计数,订阅 sessionEngine)
|
||||
- 对应旧版 apes-sidebar 的 badge 概念:tasks 徽标 = 运行中会话数;agents 徽标 = 运行子代理数。
|
||||
|
||||
### 3.4 各面板承载(渲染层职责划分)
|
||||
|
||||
| 面板 | 承载 | apes-sidebar 对应/借鉴 |
|
||||
|---|---|---|
|
||||
| files | 文件树/编辑器 | (better-sidebar 已有 editor)不重复 |
|
||||
| terminal | 终端 | (better-sidebar 已有)不重复 |
|
||||
| browser | 内嵌浏览器 | (better-sidebar 已有)不重复 |
|
||||
| tasks | Issue Manager/任务编排 | **apes:wb:tasks 升级对象**:会话/任务拓扑 |
|
||||
| apps | App Center | **apes:wb:apps 对齐对象**:应用网格+调起 |
|
||||
| messages | 消息中心(审批/通知/后台工作汇总) | **apes 可新增 wb:messages** |
|
||||
| side | Side Conversation(子会话并排) | 子代理会话展示可借鉴 |
|
||||
|
||||
Tutti 其余值得迁移的交互:控制中心(一个视图看所有 agent 对话+待审批+运行任务)、
|
||||
goal→tasks 自动拆分、@ 深度引用、task 分配给人/agent。
|
||||
|
||||
### 3.5 进程/事件模型
|
||||
|
||||
- 会话与活动数据经 `sessionEngine`/activity 服务订阅(`subscribe + getSnapshot` 模式,
|
||||
即 React 19 `useSyncExternalStore` 形状)——ape-sidebar 应把「轮询」升级为订阅 + memo。
|
||||
- 子代理/后台工作 → `subagent-live-route` / jobs 回放(better-sidebar 已实现:
|
||||
`jobs-routes.ts` 从会话事件日志回放 job 输出、`subagent-live-route.ts` 批量子代理状态)。
|
||||
|
||||
---
|
||||
|
||||
## 4. apes-sidebar v2 目标架构
|
||||
|
||||
### 4.1 定位
|
||||
|
||||
> **apes-sidebar v2 = 构建在 better-sidebar v0.18.0 之上的「工作空间指挥层」**,
|
||||
> 不重复 files/terminal/browser/editor(better-sidebar 已有),聚焦 Tutti 风格
|
||||
> **任务 / Agent / 消息 / 应用**四类信息面板 + host 侧路由与 agent 可编程驱动。
|
||||
|
||||
仍是一个 **client-half 注册型插件**(注册到 `ctx.betterSidebar`),但工程规范全面对齐 better-sidebar。
|
||||
|
||||
### 4.2 目标仓库布局
|
||||
|
||||
```
|
||||
apes-sidebar/
|
||||
├── package.json # peer: @deepseek-ai/cordis ^4.0.2 / dsh-better-sidebar ^0.18.0 / react ^18
|
||||
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫,抄 v0.18.0 写法)
|
||||
├── tsconfig.json / tsconfig.build.json
|
||||
├── tsdown.config.ts # 多产物:lib/index.js + lib/client.js + lib/client-<chunk>.js
|
||||
├── vitest.config.ts / playwright.config.ts
|
||||
├── scripts/e2e-mount.sh # scratch profile 真实挂载冒烟
|
||||
├── AGENTS.md # 仓库硬约束(参照 better-sidebar)
|
||||
├── src/
|
||||
│ ├── index.ts # host half:路由 + 工具(若需要)
|
||||
│ ├── context-types.ts # declare module '@deepseek-ai/cordis' 类型合并
|
||||
│ ├── client/
|
||||
│ │ ├── index.tsx # apply():attachLocale → 注册词典 → registerTabs
|
||||
│ │ ├── locales.ts # zh/en(跟随 ctx.locale)
|
||||
│ │ ├── api.ts # 插件自有路由 client
|
||||
│ │ ├── tabs/
|
||||
│ │ │ ├── TasksTab.tsx # 会话/任务导航(保留 wb:tasks id 兼容持久化)
|
||||
│ │ │ ├── AgentsTab.tsx # Agent/子代理拓扑(wb:agents)
|
||||
│ │ │ ├── AppsTab.tsx # 应用中心(wb:apps)
|
||||
│ │ │ ├── MessagesTab.tsx # (新)消息中心/审批汇总
|
||||
│ │ │ └── ProfileTab.tsx # 底部用户区(wb:profile)
|
||||
│ │ └── builtins.ts # 统一注册(吃自己的狗粮)
|
||||
│ └── (host routes 需要时) sidechat-routes.ts / subagent-routes.ts
|
||||
└── tests/
|
||||
├── tabs.spec.tsx / theme.spec.ts / market-manifest.spec.ts
|
||||
└── e2e/mount.e2e.ts
|
||||
```
|
||||
|
||||
### 4.3 关键工程决策
|
||||
|
||||
1. **TS + tsdown 双产物**:lib/index.js(host)、lib/client.js(client 入口)、
|
||||
chunks 按需(若引入 mermaid/图表等重依赖);`files` 白名单发布。
|
||||
2. **client inject 精简**:只声明实际 require 的宿主包
|
||||
(`@deepseek-ai/dsh-client-ui-primitives` + 服务注入 betterSidebar/sessions/workspaces/locale 经 cordis inject,不塞 dsh.client.inject)。
|
||||
3. **host half 保持最小**:纯展示可无 host;需要会话日志回放/子代理状态时新增
|
||||
better-sidebar 风格的自有路由(同源 fence + 会话隔离),不碰 DSH 源码。
|
||||
4. **Tab id 向后兼容**:`wb:tasks` / `wb:agents` / `wb:apps` / `wb:profile` 保持不变
|
||||
(避免破坏已持久化 pluginToggles / 用户布局);样式 data 属性带 `apes-sidebar-` 前缀。
|
||||
5. **配置迁移到声明式 settings**:沿用 `pluginToggles`(switch/select)机制,
|
||||
在 better-sidebar 设置页渲染;新增 `displayName` select(apes-sidebar/feyOS/自定义)。
|
||||
6. **i18n 对齐**:词典注册 `ctx.locale`(zh/en);新增 key 同步第三方语言或做 en 回退。
|
||||
7. **皮肤对齐**:全部视觉消费 `--dsw-alias-*`/`--ds-*` token,硬编码 hex 仅作链尾 fallback。
|
||||
8. **badge/reminders 对齐 Tutti**:TasksTab 徽标 = 运行中会话/任务数;AgentsTab 徽标 =
|
||||
运行子代理数(订阅 sessionEngine 而非轮询)。若可经 host 路由读 agent consumer counts,
|
||||
则消息中心徽标 = working count(对应 Tutti messageCenterWorkingCount)。
|
||||
9. **懒挂载**:未激活 tab 不渲染内容;首次打开再挂(contentReady + loading),
|
||||
与 better-sidebar TabContent 行为一致。
|
||||
10. **测试门禁**:至少 tabs 注册/开关/渲染 smoke + 挂载 e2e + 皮肤 token 守护 +
|
||||
market manifest 守护(cordis 不得出现在 dependencies)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能清单(Tutti → apes-sidebar 映射)
|
||||
|
||||
| # | 能力 | Tutti 参照 | apes-sidebar v2 落地 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| F1 | 会话/任务导航(工作区+时间分组、搜索、运行徽标) | Tasks | `wb:tasks` 升级:订阅式 + 运行中任务区 + 拓扑 | P0 |
|
||||
| F2 | Agent 总览(子代理/后台 agent、状态、跳转) | multi-agent | `wb:agents` 升级:订阅式、subagent lineage | P0 |
|
||||
| F3 | 应用中心(已装应用网格 + 调起) | App Center | `wb:apps`:appsList 可配置、viewId 调起多级降级 | P0 |
|
||||
| F4 | 消息中心/审批/工作汇总(角标) | messages + messageCenterWorkingCount | `wb:messages`(新) | P1 |
|
||||
| F5 | 任务自动拆分(goal→tasks) | Tutti goal 编排 | 规划:输入目标→子任务草稿→一键建会话(DSH 能力内简化版) | P2 |
|
||||
| F6 | 显示名/开关/子设置 | — | 声明式 settings(pluginToggles) | P0 |
|
||||
| F7 | agent 可编程开 tab | AgentToolSidebarHandle | host route + better-sidebar openTab/openFile 组合 | P1 |
|
||||
| F8 | 自动折叠/宽度记忆 | shouldAutoCollapseAgentToolSidebar | 交 better-sidebar 容器(若支持)或本地监听 | P2 |
|
||||
| F9 | Profile 用户区 | — | `wb:profile`:模型/provider/连接状态 | P0 |
|
||||
| F10 | 深色/浅色/换肤 | skin tokens | 全 token 化 | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施
|
||||
|
||||
### Phase 0:工程骨架(0.5d)
|
||||
- 建仓、tsconfig/tsdown/vitest/playwright、package.json(peer 钉 cordis ^4.0.2 + better-sidebar ^0.18.0)
|
||||
- cordis.patch.yml(v0.18.0 守卫写法)、AGENTS.md、最小 host+client 空注册冒烟
|
||||
- 通过 `pnpm test:mount` 在 scratch profile 验证挂载
|
||||
|
||||
### Phase 1:4 Tab 平移 + 质量化(1-1.5d)
|
||||
- 把旧 client.js 的 4 视图用 TSX 重写,行为不变(保留 wb:* id)
|
||||
- i18n → ctx.locale、皮肤 → token、样式 → CSS modules
|
||||
- 配置 → pluginToggles 声明式
|
||||
- 单测:注册数/开关/渲染/badge
|
||||
|
||||
### Phase 2:Tutti 能力增强(2-3d)
|
||||
- `wb:tasks`:订阅式 sessionEngine 快照 + 运行中任务区(子代理拓扑优先排序)
|
||||
- `wb:agents`:subagent lineage + 状态订阅
|
||||
- 新增 `wb:messages`(若 host 有 working count 数据源)
|
||||
- agent 可编程开 tab(F7)最小实现
|
||||
|
||||
### Phase 3:打磨与发布(1d)
|
||||
- 皮肤 token 全检、a11y、IME guard、错误边界
|
||||
- e2e 挂载冒烟入 CI、Gitea 发布 + npm 发版脚本
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证清单(每个 Phase 完成时)
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test # vitest 单测
|
||||
pnpm lint:ts
|
||||
pnpm build && pnpm pack
|
||||
pnpm test:mount # scratch profile 真实挂载(需 dsh + playwright chromium)
|
||||
# 真机:dsh plugin --profile desktop add file:<tarball> → 硬刷新 → + 菜单见新 tab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与注意
|
||||
|
||||
- **DSH rc 漂移**:better-sidebar 是 0.1.2-rc.1 线,DSH 官方 rc.x 发 next dist-tag;
|
||||
重写期间锁定 devDeps peer,升级前跑 `pnpm peers check`。
|
||||
- **不要装 dsh-background-agents 0.7.x**:与 DSH 基座 loader 条目 id 冲突 → 启动崩溃
|
||||
(历史教训),团队房间类能力暂缓。
|
||||
- **apps 调起无公开一键 API**:维持多级防御(uiConversation.activateView → slots.set → 提示)。
|
||||
- **client 纯模块表**:运行时只能 require react/react-dom/primitives 等宿主提供模块;
|
||||
其余依赖必须内联或走懒加载 chunk,否则构建纯度门会拦。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Word 缩放与 PDF 拖拽修复设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
|
||||
## Word 缩放
|
||||
|
||||
- 默认 100%,范围 50%–200%,步进 10%。
|
||||
- `Alt + 滚轮` 调整缩放并阻止 viewport 默认滚动。
|
||||
- 底部固定 range slider,同步显示百分比。
|
||||
- 使用 CSS `zoom` 缩放 docx-preview 专属容器,使滚动尺寸同步变化。
|
||||
- 切换 Word 文件时恢复 100%。
|
||||
|
||||
## PDF 拖拽
|
||||
|
||||
- iframe 放入独立 stage。
|
||||
- stage 内常驻透明 drag shield。
|
||||
- Tab 拖拽或面板/分栏调整期间立即启用 shield;结束、取消或窗口失焦时关闭。
|
||||
- shield 捕获 Chromium 内置 PDF viewer 吞掉的 dragover/pointer 流,正常状态不影响 PDF 交互。
|
||||
@@ -0,0 +1,364 @@
|
||||
# Office 三件套预览 — 设计文档
|
||||
|
||||
> 日期:2026-08-10
|
||||
> 状态:已批准(待实现)
|
||||
> 范围:`dsh-better-sidebar` 插件,`EditorView` 文件类型扩展
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `EditorView.tsx` 对 `.docx/.xlsx/.pptx` 及旧格式 `.doc/.xls/.ppt` 一律显示 binary 占位(`EditorView.tsx:210`),因为 host 的 `readText` 检测到 NUL 字节就标记 binary(`src/index.ts:108`),而 Office 文件本质是 zip。
|
||||
|
||||
**目标**:为 Office 三件套新增有意义的预览能力,保持插件现有架构纪律(host 零改动、bundle 纯度门、跨平台、失败模式全覆盖)。
|
||||
|
||||
**非目标**:
|
||||
- 不做 Office 文件编辑(只预览)
|
||||
- 不做 `push/pull/fetch` 之类网络 Git 操作(与本次无关,沿用 v1 限制)
|
||||
- 不做文件 watcher
|
||||
|
||||
## 2. 范围与选型
|
||||
|
||||
| 类型 | 方案 | 库 | 加载策略 | host 改动 |
|
||||
|---|---|---|---|---|
|
||||
| `.docx` | 保真渲染(保留样式/页眉页脚/图片/表格) | `docx-preview` | 静态 import(~500KB,小) | 零 |
|
||||
| `.xlsx` | 完整电子表格(公式/图表/条件格式/多 sheet) | `@univerjs/*` 全家桶 + `xlsx`(SheetJS,导入) | `dynamic import()` 懒加载(~5MB,首次打开 .xlsx 才加载) | 零 |
|
||||
| `.pptx` | 降级下载按钮 | — | — | — |
|
||||
| `.doc/.xls/.ppt`(OLE 二进制) | 降级下载按钮 | 纯前端无成熟库 | — | — |
|
||||
|
||||
**决策依据**:
|
||||
- 纯前端路线符合现有 bundle 纯度门(`tsdown.config.ts:128` 的 purity gate 只禁 `@deepseek-ai/*` value import,普通 npm 包内联不受限)与跨平台原则(无需 host 装重依赖)。
|
||||
- `pptx` 纯前端无成熟渲染库( OOXML slides + layouts + masters + shapes 解析极复杂),YAGNI 降级。
|
||||
- 旧 OLE 二进制格式(`.doc/.xls/.ppt`)纯前端基本无解,降级。
|
||||
- Univer 选 dynamic import 懒加载:静态内联会让 client bundle 从 2MB→7MB(gzip 1.5MB→3MB),首屏负担过重。
|
||||
|
||||
## 3. 架构
|
||||
|
||||
### 3.1 host 半:零改动
|
||||
|
||||
复用现有 `/sidebar/file` media route(`src/index.ts:349-392`):
|
||||
- 该路由已支持任意文件字节(不限扩展名,只检查 `isWithin(cwd, path)` + `isFile` + `size <= mediaLimit`)
|
||||
- `MEDIA_TYPES`(`index.ts:44`)对 Office 扩展名 fallback 到 `application/octet-stream`,client 用 `fetch` 拿 ArrayBuffer,不依赖 content-type
|
||||
- `mediaLimit` 默认 20MB,对 Office 文件足够(超限降级下载)
|
||||
|
||||
**不改 `fs.read`**:Office 文件不走文本读取路径(会被 NUL 探测当 binary 拒绝),直接走 media route。
|
||||
|
||||
### 3.2 client 半:EditorView 类型扩展
|
||||
|
||||
`EditorLoad` 类型(`EditorView.tsx:26`)扩展:
|
||||
|
||||
```ts
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx'; content: string; truncated: boolean }
|
||||
| { status: 'binary' } // 现保留:未知二进制 / pptx / OLE 旧格式 → 下载按钮
|
||||
```
|
||||
|
||||
加载流程(`EditorView.tsx:60` 的 `useEffect`):
|
||||
1. `api.fsRead(scope, path)` 仍调(用于探测 binary/text)— 对 Office 文件会返回 `{kind:'binary'}`,据此分流
|
||||
2. 识别扩展名 ∈ `{.docx, .xlsx}` → `setLoad({ status:'ready', kind:'docx'|'xlsx', content:'', truncated:false })`
|
||||
3. 识别 `.pptx` / `.doc` / `.xls` / `.ppt` / 其他未知 binary → 保持 `{status:'binary'}`,但 binary 占位改为"下载查看"按钮(见 §3.5)
|
||||
4. `IMAGE_EXT` / `MD_EXT` 分支不变
|
||||
|
||||
> 备注:step 1 的 `fsRead` 对 Office 文件是冗余往返(必定返回 binary)。可选优化:client 侧先按扩展名短路,Office 扩展名直接走 media route 不调 `fsRead`。实现时取短路方案,省一次请求。
|
||||
|
||||
### 3.3 文件类型分发(纯函数,易测)
|
||||
|
||||
新增 `src/client/office-types.ts`:
|
||||
|
||||
```ts
|
||||
export const OFFICE_PREVIEWABLE = ['.docx', '.xlsx'] as const
|
||||
export const OFFICE_DOWNLOAD_ONLY = ['.pptx', '.doc', '.xls', '.ppt'] as const
|
||||
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'download-only' | null
|
||||
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === '.docx') return 'docx'
|
||||
if (ext === '.xlsx') return 'xlsx'
|
||||
if (['.pptx', '.doc', '.xls', '.ppt'].includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
`EditorView` 的 `extOfPath` 已有(`EditorView.tsx:235`),复用。
|
||||
|
||||
### 3.4 DocxView / XlsxView 组件
|
||||
|
||||
新增 `src/client/office-view.tsx`,导出 `DocxView` 与 `XlsxView`,各自管理库生命周期。
|
||||
|
||||
#### DocxView
|
||||
|
||||
```tsx
|
||||
export function DocxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
const { renderAsync } = await import('docx-preview') // 静态 import 亦可,此处用动态保持一致
|
||||
await renderAsync(buf, container, null, { className: 'docx', inWrapper: true })
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: error instanceof Error ? error.message : String(error)})
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true; container.innerHTML = '' }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- `docx-preview` 的 `renderAsync(arrayBuffer, container, null, options)` 把 docx 渲染到 container,保留样式/页眉页脚/图片(图片库内部 base64 内联处理)
|
||||
- 卸载清空 `container.innerHTML`(docx-preview 无 dispose API,DOM 清空即可)
|
||||
|
||||
#### XlsxView
|
||||
|
||||
```tsx
|
||||
export function XlsxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<Univer | null>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
// 懒加载 Univer 全家桶(首次打开 .xlsx 才下载 ~5MB chunk)
|
||||
const { createUniver, defaultLocale } = await import('@univerjs/core')
|
||||
const { UniverSheetsCorePlugin } = await import('@univerjs/sheets-ui')
|
||||
const { UniverSheetsFormulaPlugin } = await import('@univerjs/sheets-formula')
|
||||
const { UniverSheetsConditionalFormattingPlugin } = await import('@univerjs/sheets-conditional-formatting')
|
||||
const { UniverSheetsChartPlugin } = await import('@univerjs/sheets-chart')
|
||||
const { UniverDocsPlugin } = await import('@univerjs/docs-ui')
|
||||
// xlsx 导入:SheetJS 解析后转 Univer workbook data
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const workbookData = xlsxWorkbookToUniver(wb) // 转换函数,见 §3.6
|
||||
|
||||
const univer = createUniver({
|
||||
locale: defaultLocale,
|
||||
plugins: [
|
||||
UniverDocsPlugin,
|
||||
UniverSheetsCorePlugin,
|
||||
UniverSheetsFormulaPlugin,
|
||||
UniverSheetsConditionalFormattingPlugin,
|
||||
UniverSheetsChartPlugin,
|
||||
],
|
||||
})
|
||||
univer.createUniverSheet(workbookData)
|
||||
univerRef.current = univer
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: ...})
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
univerRef.current?.dispose() // 关键:防 canvas + worker 泄漏
|
||||
univerRef.current = null
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- **dispose 纪律**:卸载必须 `univer.dispose()`,否则 canvas + worker 泄漏(类比 `TerminalView` 的 `term.dispose()` + `observer.disconnect()`)
|
||||
- Univer 的 UI 需要容器有尺寸(类似 xterm),复用 `ResizeObserver` 模式
|
||||
|
||||
### 3.5 binary 占位升级为下载按钮
|
||||
|
||||
`EditorView.tsx:210` 现状:`{t('binary')}` 纯文本占位。
|
||||
|
||||
改为:
|
||||
|
||||
```tsx
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- 复用现有 `downloadUrl(scope, path)`(`api.ts:140`),走 `/sidebar/file?download=1`
|
||||
- 对所有 binary 统一(不只 Office)— PDF、zip、exe 等都受益
|
||||
|
||||
### 3.6 xlsx → Univer workbook data 转换
|
||||
|
||||
SheetJS 解析的 workbook 结构与 Univer 的 `IWorkbookData` 不同,需转换层 `src/client/xlsx-to-univer.ts`:
|
||||
|
||||
- SheetJS `wb.SheetNames[]` + `wb.Sheets[name]` → Univer `sheets: Record<sheetId, IWorksheetData>`
|
||||
- 单元格:`ws[addr]` → `cellData[row][col]`,映射类型(s/n/b/e)、值、样式(对齐/字体/背景/边框)
|
||||
- 合并单元格:`ws['!merges']` → `mergeData`
|
||||
- 列宽/行高:`ws['!cols']` / `ws['!rows']` → `columnData` / `rowData`
|
||||
- 公式:SheetJS `cell.f` → Univer 公式格式
|
||||
- 图表/条件格式:SheetJS 不解析这些,需 Univer importer 或跳过(首版可只迁数据+样式+合并+公式)
|
||||
|
||||
**首版范围**:数据 + 基本样式(字体/颜色/对齐/边框/合并)+ 公式(文本形式即可,Univer 引擎计算)。图表/条件格式若 Univer importer 直接支持则用,否则跳过。
|
||||
|
||||
> 备选:用 Univer 官方的 `@univerjs/sheets-import-export`(基于 SheetJS 封装,直接产出 `IWorkbookData`),省去自写转换层。实现时优先评估此包,若版本耦合/体积可接受则用,否则自写。
|
||||
|
||||
### 3.7 EditorView 集成
|
||||
|
||||
`EditorView.tsx` 的 `renderTab` / `TabContent` 分发(`Sidebar.tsx:49` 的 switch)无需改 — editor tab 仍走 `EditorView`。
|
||||
|
||||
`EditorView` 内部 `useEffect`(`EditorView.tsx:60`)加 Office 分支:
|
||||
|
||||
```ts
|
||||
const ext = extOfPath(path)
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return // 不再走 fsRead(短路,省一次 binary 往返)
|
||||
}
|
||||
// 原有 IMAGE / MD / text / binary 逻辑不变
|
||||
```
|
||||
|
||||
渲染区(`EditorView.tsx:220` 附近)加:
|
||||
|
||||
```tsx
|
||||
{load.status === 'ready' && load.kind === 'docx' && (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
```
|
||||
|
||||
## 4. 错误处理
|
||||
|
||||
| 场景 | 检测 | 响应 |
|
||||
|---|---|---|
|
||||
| 文件损坏(非有效 zip) | 库抛错 | 显示错误信息 + 下载按钮 |
|
||||
| `>20MB`(超 `mediaLimit`) | fetch 响应 / host 返回 400 | 提示"文件过大" + 下载按钮 |
|
||||
| 加密文件(密码保护) | SheetJS/docx-preview 抛特定错 | "不支持加密文件" + 下载按钮 |
|
||||
| Univer chunk 加载失败 | dynamic import reject | 重试按钮(类比 `TerminalView` 的 `FAILURE_LIMIT`) |
|
||||
| 网络失败(媒体路由 403) | fetch 非 200 | 错误信息(信任围栏拒绝) |
|
||||
| Univer dispose 后异步回调 | cancelled flag | 静默(类比现有 `TerminalView` 模式) |
|
||||
|
||||
**降级统一出口**:所有 Office 错误分支都附带"下载查看"链接,确保用户始终能拿到文件。
|
||||
|
||||
## 5. bundle 与构建
|
||||
|
||||
### 5.1 依赖新增(`package.json`)
|
||||
|
||||
`dependencies`(运行时,内联进 client bundle):
|
||||
- `docx-preview`(~500KB)
|
||||
- `xlsx`(SheetJS,~400KB,xlsx 导入)
|
||||
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
|
||||
|
||||
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
|
||||
|
||||
### 5.2 tsdown.config.ts 调整
|
||||
|
||||
现有 client bundle 配置(`tsdown.config.ts:103` 的 `clientBundle()`):
|
||||
- `format: 'cjs'` + `banner`/`footer` 包成 `__ModuleLoader__.load` 闭包工厂
|
||||
- `noExternal: (id) => (CLIENT_EXTERNALS.includes(id) ? undefined : true)` — 非 externals 全内联
|
||||
|
||||
**dynamic import 兼容性(最大实现风险)**:
|
||||
|
||||
Rollup 在 `format: 'cjs'` + `inlineDynamicImports: false` 时会拆 chunk,但 CJS chunk 在浏览器端无 `require`,动态导入链路可能断裂。需 spike 验证:
|
||||
|
||||
1. **首选**:保持 `format: 'cjs'`,让 rollup 拆 chunk,验证浏览器 `import()` 是否能加载子 chunk(rollup 会输出 `import()` 调用,浏览器原生支持 ESM dynamic import,但 CJS chunk 的 `module.exports` 形态需 wrapper)
|
||||
2. **fallback A**:退回静态内联(放弃懒加载,接受 7MB bundle)
|
||||
3. **fallback B**:Univer 单独打 IIFE bundle `lib/univer-bundle.js`,host 新增静态路由 `/sidebar/univer-bundle.js` serve,client 用 `<script>` 动态注入(破坏 host 零改动,最后手段)
|
||||
|
||||
**spike 必须是实现第一步**,结果决定后续构建配置。
|
||||
|
||||
### 5.3 CSS
|
||||
|
||||
Univer 自带样式,需确保:
|
||||
- Univer 的 CSS 被 `dsh-css-inline` plugin 正确内联(走 `import '@univerjs/.../style.css'`)
|
||||
- 或 Univer 的样式注入机制不与现有 `data-plugin-css` tag 冲突
|
||||
|
||||
docx-preview 的样式由 `className: 'docx'` 选项控制,可能需补 `src/client/office.module.css` 微调。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
### 6.1 Unit(纯函数,加进 `tests/unit.spec.ts`)
|
||||
|
||||
- `officeKindForExt('.docx')` → `'docx'`
|
||||
- `officeKindForExt('.xlsx')` → `'xlsx'`
|
||||
- `officeKindForExt('.pptx')` → `'download-only'`
|
||||
- `officeKindForExt('.doc')` → `'download-only'`
|
||||
- `officeKindForExt('.txt')` → `null`
|
||||
- `officeKindForExt('')` → `null`
|
||||
|
||||
### 6.2 xlsx → Univer 转换(若自写转换层)
|
||||
|
||||
- fixture xlsx → 解析 → 转换 → 断言 `IWorkbookData` 结构(sheet 名/单元格值/合并/公式)
|
||||
- 用 `tests/fixtures/sample.xlsx` 作为输入
|
||||
|
||||
### 6.3 集成测试限制
|
||||
|
||||
- Univer 依赖 canvas API,jsdom 环境无法渲染 → 不测 Univer 渲染产物
|
||||
- 只测加载逻辑:mock `fetch` 返回 ArrayBuffer,验证 loading → ready/error 状态转换
|
||||
- docx-preview 同样依赖 DOM,jsdom 可跑 `renderAsync`(无 canvas 依赖)但渲染产物脆弱 → 只测"不抛错"
|
||||
|
||||
### 6.4 手动冒烟
|
||||
|
||||
- 真实 .docx(含图片/表格/页眉)→ 渲染保真度
|
||||
- 真实 .xlsx(多 sheet/公式/条件格式)→ 渲染 + 公式计算
|
||||
- .pptx → 下载按钮
|
||||
- 加密 docx/xlsx → 错误提示 + 下载
|
||||
- >20MB → 提示 + 下载
|
||||
|
||||
## 7. 国际化
|
||||
|
||||
`src/client/locales.ts` 新增 key:
|
||||
|
||||
- `downloadToView` — "下载查看" / "Download to view"
|
||||
- `binaryNoPreview` — "此文件类型不支持预览" / "This file type cannot be previewed"
|
||||
- `officeTooLarge` — "文件过大,无法预览" / "File too large to preview"
|
||||
- `officeCorrupt` — "文件损坏或格式无效" / "File is corrupt or in an invalid format"
|
||||
- `officeEncrypted` — "不支持加密文件" / "Encrypted files are not supported"
|
||||
- `officeLoadFailed` — "Office 预览组件加载失败" / "Office preview component failed to load"
|
||||
- `retry` — 复用现有 `terminalRetry` 或新增 `retry`
|
||||
|
||||
## 8. 文件改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/client/EditorView.tsx` | 加 docx/xlsx kind 分支 + 下载按钮 binary 占位 |
|
||||
| `src/client/office-view.tsx`(新) | `DocxView` / `XlsxView` 组件 |
|
||||
| `src/client/office-types.ts`(新) | 扩展名 → kind 纯函数 |
|
||||
| `src/client/xlsx-to-univer.ts`(新,若自写转换) | SheetJS workbook → Univer IWorkbookData |
|
||||
| `src/client/locales.ts` | 新文案 key |
|
||||
| `src/client/sidebar.module.css` | `.editorBinary` / `.editorDownloadLink` 样式 |
|
||||
| `package.json` | 加 docx-preview / @univerjs/* / xlsx 依赖 |
|
||||
| `tsdown.config.ts` | dynamic import 配置(spike 后定) |
|
||||
| `tests/unit.spec.ts` | officeKindForExt 单测 |
|
||||
| `tests/fixtures/`(新) | sample.docx / sample.xlsx 测试夹具 |
|
||||
|
||||
## 9. 风险登记
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解 |
|
||||
|---|---|---|---|
|
||||
| tsdown CJS 闭包不兼容 dynamic import | 中 | 高(阻塞懒加载) | spike 优先;fallback A 静态内联 / fallback B 独立 bundle |
|
||||
| Univer 体积超预期(>5MB) | 低 | 中 | 评估 tree-shaking;裁剪非必要插件(如 chart) |
|
||||
| xlsx → Univer 转换层复杂度爆表 | 中 | 中 | 优先用 `@univerjs/sheets-import-export` 官方包 |
|
||||
| Univer canvas 在某些浏览器渲染异常 | 低 | 中 | 错误边界捕获 + 下载降级 |
|
||||
| docx-preview 图片 base64 内存爆炸(大 docx) | 低 | 低 | 文件 size cap 已 20MB,可接受 |
|
||||
| Univer dispose 不彻底导致内存泄漏 | 中 | 中 | 严格 cleanup + 手动验证 Tab 切换/卸载 |
|
||||
|
||||
## 10. 实现里程碑(粗略,待 writing-plans 细化)
|
||||
|
||||
1. **Spike**:验证 tsdown CJS + dynamic import 兼容性(决定构建策略)
|
||||
2. **基础**:office-types.ts + EditorView 分发 + binary 下载按钮(最小可用,pptx/旧格式先受益)
|
||||
3. **docx**:DocxView + docx-preview 集成 + 错误处理
|
||||
4. **xlsx**:XlsxView + Univer 集成 + xlsx 导入(优先官方 importer)
|
||||
5. **测试**:unit + fixture + 手动冒烟
|
||||
6. **收尾**:CSS 打磨 + 国际化 + 文档更新(README 功能一览)
|
||||
@@ -0,0 +1,31 @@
|
||||
# PDF 原生预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `EditorView` 文件预览扩展
|
||||
|
||||
## 目标
|
||||
|
||||
为 `.pdf` 增加侧边栏内预览,同时保持零新增前端依赖和现有下载降级出口。
|
||||
|
||||
## 方案
|
||||
|
||||
- Client 按扩展名识别 `.pdf`,短路 `fsRead` 的二进制探测。
|
||||
- `PdfView` 通过 `fetch(mediaUrl(scope, path))` 读取字节,创建显式
|
||||
`application/pdf` Blob URL 后交给浏览器原生 `<iframe>`,避免旧 host
|
||||
或代理缓存 `application/octet-stream` 时触发直接下载。
|
||||
- Host media route 为 `.pdf` 返回 `application/pdf`。
|
||||
- PDF 预览工具条始终提供“下载查看”链接。
|
||||
- 不引入 PDF.js,不实现缩略图、文本搜索或自定义翻页工具栏。
|
||||
|
||||
## 错误与限制
|
||||
|
||||
- 是否可渲染取决于浏览器内置 PDF 查看器。
|
||||
- 文件仍受现有 `mediaLimit` 限制。
|
||||
- 浏览器原生 PDF iframe 对 HTTP 错误没有统一可观测事件;下载链接作为稳定降级出口。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pdf` 扩展名识别为 PDF 预览。
|
||||
- media type helper 对 `.pdf` 返回 `application/pdf`。
|
||||
- Typecheck、bundle purity、manifest/plugin-shape 测试通过。
|
||||
@@ -0,0 +1,37 @@
|
||||
# PPTX 高保真预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `.pptx` 浏览器预览
|
||||
|
||||
## 目标
|
||||
|
||||
使用 `@aiden0z/pptx-renderer` 在侧边栏内预览 `.pptx`,保留图片、表格、
|
||||
图表、SmartArt 与常见 OOXML 样式,同时保持下载降级出口。
|
||||
|
||||
## 架构
|
||||
|
||||
- `.pptx` 从 `download-only` 调整为独立 `pptx` 预览类型。
|
||||
- `PptxView` 从现有 media route 获取 `ArrayBuffer`。
|
||||
- 使用 `PptxViewer.open()` 渲染到库专属 DOM 容器。
|
||||
- 开启 `lazyMedia`、`lazySlides` 与推荐 ZIP 安全限制。
|
||||
- UI 提供上一页、下一页、页码和下载按钮。
|
||||
- 卸载时销毁 viewer 并清空库专属容器。
|
||||
- 旧 `.ppt/.doc/.xls` 继续仅下载。
|
||||
|
||||
## Bundle
|
||||
|
||||
- 先验证普通 bundler 入口不会遗留 Node builtin `require()`。
|
||||
- 若普通入口不纯,切换包提供的 standalone browser ESM 入口。
|
||||
- 不启用可选 PDF.js EMF fallback,避免额外体积。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 损坏、加密或超限文件显示错误信息和下载链接。
|
||||
- viewer 初始化失败时清理部分创建的实例与 DOM。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pptx` 类型分发。
|
||||
- bundle 模块表 require guard。
|
||||
- Typecheck、manifest/plugin-shape 与构建通过。
|
||||
@@ -0,0 +1,137 @@
|
||||
# 侧边栏设置声明式化 + 内置支持"插头化"设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.4.1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
|
||||
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
|
||||
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
|
||||
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
|
||||
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
|
||||
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
|
||||
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
|
||||
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
|
||||
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
|
||||
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
openByDefault: boolean
|
||||
defaultWidthPercent: number
|
||||
autoOpenSubagent: boolean
|
||||
agentTerminalTools: boolean
|
||||
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
|
||||
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
|
||||
}
|
||||
```
|
||||
|
||||
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
|
||||
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
|
||||
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
|
||||
|
||||
### 4.2 声明式描述符(`client/service.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarSettingToggle {
|
||||
key: string // SidebarPrefs 字段名('autoOpenSubagent')
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
interface SidebarSettingsDeclaration {
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
// TabDescriptor 增加:
|
||||
settings?: SidebarSettingsDeclaration
|
||||
|
||||
// FileViewerDescriptor 增加(设置清单展示用):
|
||||
title?: string | (() => string) // 缺省回退 id
|
||||
icon?: ReactNode | ((size) => ReactNode)
|
||||
settings?: SidebarSettingsDeclaration
|
||||
```
|
||||
|
||||
服务新增:
|
||||
```ts
|
||||
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
|
||||
isViewerEnabled(id: string): boolean
|
||||
```
|
||||
|
||||
行为变化:
|
||||
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
|
||||
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
|
||||
|
||||
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
|
||||
|
||||
| tab | 相关设置声明 |
|
||||
|---|---|
|
||||
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
|
||||
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
|
||||
| explorer/git/editor/diff | 无(仅开关) |
|
||||
|
||||
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
|
||||
|
||||
### 4.4 Store 快照携带 prefs(`client/state.ts`)
|
||||
|
||||
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
|
||||
|
||||
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
|
||||
|
||||
### 4.5 消费点 gating
|
||||
|
||||
| 消费点 | 行为 |
|
||||
|---|---|
|
||||
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
|
||||
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
|
||||
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
|
||||
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
|
||||
| `openTab` / `matchFileViewer` | 见 §4.2 |
|
||||
| 新会话默认布局 | explorer 禁用 → 空 pane |
|
||||
|
||||
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
|
||||
|
||||
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
|
||||
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
|
||||
- 行结构:
|
||||
- **常规**:`openByDefault`、宽度(既有逻辑)。
|
||||
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
|
||||
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
|
||||
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
|
||||
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
|
||||
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
|
||||
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
|
||||
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
|
||||
|
||||
## 6. 限制与失败模式
|
||||
|
||||
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
|
||||
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
|
||||
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
|
||||
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
|
||||
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
|
||||
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
|
||||
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
|
||||
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
|
||||
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
|
||||
@@ -0,0 +1,416 @@
|
||||
# DSH-better-sidebar 服务化改造设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已批准,待实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.3.0(main `069c754`)
|
||||
**目标版本**:v0.4.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
|
||||
|
||||
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
|
||||
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
|
||||
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
|
||||
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
|
||||
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
### 3.1 Tab 系统硬编码点(7 处)
|
||||
|
||||
| 文件:行 | 用途 |
|
||||
|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
|
||||
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
|
||||
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
|
||||
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
|
||||
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
|
||||
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
|
||||
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
|
||||
|
||||
### 3.2 文件预览硬编码点
|
||||
|
||||
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
|
||||
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
|
||||
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
|
||||
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
|
||||
|
||||
### 3.3 服务暴露现状
|
||||
|
||||
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
|
||||
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
|
||||
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
|
||||
|
||||
### 3.4 平台约束
|
||||
|
||||
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
|
||||
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
|
||||
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
|
||||
|
||||
## 4. 方案选型
|
||||
|
||||
| 方案 | 机制 | 评估 |
|
||||
|---|---|---|
|
||||
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
|
||||
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
|
||||
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
|
||||
|
||||
**采纳方案 A**。
|
||||
|
||||
## 5. 服务接口设计
|
||||
|
||||
### 5.1 主服务接口
|
||||
|
||||
```ts
|
||||
// 新文件 src/client/api.ts
|
||||
export interface BetterSidebarService {
|
||||
/** 注册一种 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册一种文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 工具方法:让外部 tab 也能开/关 tab */
|
||||
openTab(tab: { type: string; title: string; path?: string }): void
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅 registry 变化(注册/卸载时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 TabDescriptor
|
||||
|
||||
```ts
|
||||
export interface TabDescriptor {
|
||||
id: string // 唯一,如 'explorer' / 'my-plugin:db'
|
||||
title: string | (() => string) // i18n 友好
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
order?: number // + 菜单排序,默认 100
|
||||
single?: boolean // 单实例(explorer/git/subagent)
|
||||
hidden?: boolean // 不出现在 + 菜单(editor 用)
|
||||
available?: (ctx: Context, scope: SessionScope) => boolean
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 FileViewerDescriptor
|
||||
|
||||
```ts
|
||||
export interface FileViewerDescriptor {
|
||||
id: string // 'image' / 'pdf' / 'my-plugin:csv'
|
||||
exts: readonly string[] // 小写无点,如 ['png','jpg']
|
||||
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Cordis 类型合并 outlet
|
||||
|
||||
```ts
|
||||
// 新文件 src/types.ts (纯类型)
|
||||
import type { BetterSidebarService } from './client/api'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
betterSidebar: BetterSidebarService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
|
||||
|
||||
## 7. package.json exports 新增
|
||||
|
||||
```jsonc
|
||||
"exports": {
|
||||
".": { ... },
|
||||
"./invariant": { ... },
|
||||
"./client": { ... },
|
||||
"./client/api": { // 新增,纯类型导出
|
||||
"types": "./lib/types/client/api.d.ts",
|
||||
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
```
|
||||
|
||||
`files` 字段相应加 `lib/types/client/api.d.ts`。
|
||||
|
||||
## 8. 内置 tab 自我注册(吃狗粮)
|
||||
|
||||
`src/client/index.tsx` 的 `apply()` 内:
|
||||
|
||||
```ts
|
||||
const service = createBetterSidebarService(store)
|
||||
ctx.provide('betterSidebar', service)
|
||||
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
|
||||
```
|
||||
|
||||
### 8.1 5 个内置 tab
|
||||
|
||||
| id | order | single | hidden | available | 组件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
|
||||
| `explorer` | 10 | true | false | always | `ExplorerView` |
|
||||
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
|
||||
| `subagent` | 30 | true | false | always | `SubagentView` |
|
||||
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
|
||||
|
||||
### 8.2 内置 file viewer
|
||||
|
||||
| id | exts | priority | fetchStrategy | 组件 |
|
||||
|---|---|---|---|---|
|
||||
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
|
||||
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
|
||||
| `docx` | docx | 0 | mediaUrl | `DocxView` |
|
||||
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
|
||||
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
|
||||
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
|
||||
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
|
||||
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
|
||||
|
||||
`matchFileViewer(path, head?)` 算法:
|
||||
1. 按 `priority` 降序遍历
|
||||
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
|
||||
3. 否则匹配 `exts`(小写无点)
|
||||
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
|
||||
|
||||
## 9. 7 处硬编码 switch 改造点
|
||||
|
||||
| 文件:行 | 原状 | 改造后 |
|
||||
|---|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
|
||||
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
|
||||
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
|
||||
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
|
||||
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
|
||||
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
|
||||
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
|
||||
|
||||
## 10. 文件预览路由改造
|
||||
|
||||
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path, head?)
|
||||
if (!viewer || viewer.fetchStrategy === 'binary-download') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
|
||||
case 'fsRead':
|
||||
api.fsRead(scope, path).then(({ content, truncated }) =>
|
||||
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
|
||||
)
|
||||
break
|
||||
case 'custom':
|
||||
viewer.load(path, scope).then(data =>
|
||||
setLoad({ status: 'ready', kind: 'custom', customData: data })
|
||||
)
|
||||
break
|
||||
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path)
|
||||
if (!viewer) return <BinaryDownload ... />
|
||||
return <viewer.component {...props} />
|
||||
```
|
||||
|
||||
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
|
||||
|
||||
## 11. 持久化与降级
|
||||
|
||||
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
|
||||
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
|
||||
|
||||
## 12. Consumer 插件使用范例
|
||||
|
||||
### 12.1 Consumer `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 Consumer client half
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.tsx
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
|
||||
export const inject = ['betterSidebar', 'slots']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ scope, store, tab }) => <DbView scope={scope} />,
|
||||
})
|
||||
)
|
||||
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
### 13.1 单元测试
|
||||
|
||||
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
|
||||
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
|
||||
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
|
||||
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
|
||||
|
||||
### 13.2 集成测试
|
||||
|
||||
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
|
||||
|
||||
### 13.3 现有 manifest 守卫
|
||||
|
||||
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
|
||||
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
|
||||
|
||||
## 14. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
|
||||
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
|
||||
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
|
||||
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
|
||||
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
|
||||
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
|
||||
|
||||
## 15. 版本与发布
|
||||
|
||||
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
|
||||
- peerDependencies 不变(不加新依赖)
|
||||
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
|
||||
- 重建 `lib/` + 推送 main + profile 更新
|
||||
|
||||
## 16. 实施顺序(高层)
|
||||
|
||||
1. 拉取 main v0.3.0 到工作分支
|
||||
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
|
||||
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
|
||||
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
|
||||
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
|
||||
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
|
||||
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
|
||||
8. `package.json` 加 `./client/api` exports + `files` 字段
|
||||
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
|
||||
10. 写测试(service / matchFileViewer / builtins / orphaned)
|
||||
11. typecheck + test + build 三件套
|
||||
12. 重建 lib/,提交,推送,更新 profile web
|
||||
|
||||
详细实施计划由 writing-plans skill 生成。
|
||||
|
||||
---
|
||||
|
||||
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
|
||||
|
||||
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
|
||||
|
||||
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
|
||||
|
||||
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
|
||||
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
|
||||
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
|
||||
|
||||
### 17.2 `available` 三参签名
|
||||
|
||||
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
|
||||
|
||||
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
|
||||
|
||||
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
|
||||
|
||||
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
|
||||
|
||||
§10 的 EditorView 职责一分为二:
|
||||
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
|
||||
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
|
||||
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
|
||||
|
||||
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
|
||||
|
||||
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
|
||||
|
||||
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
|
||||
|
||||
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
|
||||
|
||||
### 17.7 + 菜单:disabled 行而非 filter
|
||||
|
||||
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
|
||||
|
||||
### 17.8 保留的 store 级操作(非注册表职责)
|
||||
|
||||
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
|
||||
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
|
||||
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
|
||||
|
||||
### 17.9 内置清单
|
||||
|
||||
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
|
||||
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
|
||||
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
|
||||
|
||||
### 17.10 测试
|
||||
|
||||
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.4.3",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
"contributes": {
|
||||
"tools": [],
|
||||
"skills": []
|
||||
},
|
||||
"client": {
|
||||
"main": "./lib/client-registry.js"
|
||||
}
|
||||
}
|
||||
+126
-51
@@ -1,69 +1,144 @@
|
||||
{
|
||||
"name": "apes-sidebar",
|
||||
"version": "2.0.0",
|
||||
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.4.3",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./invariant": "./lib/invariant.js",
|
||||
"./client": "./lib/client.js",
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src",
|
||||
"cordis.patch.yml",
|
||||
"scripts",
|
||||
"AGENTS.md",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"verify:mount": "node scripts/verify-mount.cjs",
|
||||
"lint:ts": "oxlint src tests tsdown.config.ts vitest.config.ts",
|
||||
"dev": "tsdown --watch",
|
||||
"pack:plugin": "pnpm build && pnpm pack --pack-destination dist"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dsh-better-sidebar": "^0.18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"dsh-better-sidebar": "^0.18.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"oxlint": "^0.15.0",
|
||||
"react": "~18.3.1",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"betterSidebar",
|
||||
"locale",
|
||||
"sessions"
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"license": "MIT"
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||
"@univerjs/presets": "^0.25.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"lightningcss": "^1.32.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4861
-1841
File diff suppressed because it is too large
Load Diff
+64
-1
@@ -1,4 +1,67 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
# node-pty's install script copies its shipped prebuilt binaries into place
|
||||
# (no source compilation on supported platforms); pnpm 10+ blocks dependency
|
||||
# build scripts unless approved.
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar@0.18.0
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
|
||||
- '@deepseek-ai/cordis@4.0.1-rc.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-runtime@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-code-runtime@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Assemble the plugin-registry installable root (`registry/`) from the built
|
||||
* `lib/` output. Run after `pnpm build`:
|
||||
*
|
||||
* pnpm build
|
||||
* node scripts/package-registry.mjs
|
||||
* dsh registry install ./registry
|
||||
* dsh registry enable dsh-external/dsh-better-sidebar
|
||||
*
|
||||
* The registry install copies the whole source directory (`cp -r`), so
|
||||
* installing the repo root would drag in `node_modules/` (machine-local
|
||||
* `link:` devDependency symlinks, node-pty binaries) and `.git/`. This
|
||||
* staging directory carries exactly the files the manifest references plus
|
||||
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
|
||||
* gitignored and rebuilt from scratch on every run.
|
||||
*/
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
|
||||
const out = join(root, 'registry')
|
||||
|
||||
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
|
||||
const files = [
|
||||
'dsh.plugin.json',
|
||||
'lib/index.js',
|
||||
'lib/invariant.js',
|
||||
'lib/client-registry.js',
|
||||
'lib/client-registry.js.map',
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
]
|
||||
|
||||
rmSync(out, { recursive: true, force: true })
|
||||
mkdirSync(out, { recursive: true })
|
||||
for (const file of files) {
|
||||
const source = join(root, file)
|
||||
if (!existsSync(source)) {
|
||||
console.error(`missing ${file} — run \`pnpm build\` first`)
|
||||
process.exit(1)
|
||||
}
|
||||
const target = join(out, file)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
cpSync(source, target)
|
||||
}
|
||||
console.log(`registry/ assembled (${files.length} files)`)
|
||||
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 验收验证:模拟 DSH host 的 resolveOverlayPackage + bundle 加载
|
||||
* - 确认 apes-sidebar 在 profile node_modules 可解析
|
||||
* - 确认 host entry (lib/index.js) 的命名导出 shape { name, inject, apply }
|
||||
* - 确认 client entry (lib/client.js) 存在且是 __ModuleLoader__ bundle(browser artifact)
|
||||
* - 确认 invariant surface 可用
|
||||
*/
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { createRequire } = require('module')
|
||||
|
||||
const PROFILE = process.env.HOME + '/.dsh/profiles/desktop'
|
||||
const req = createRequire(path.join(PROFILE, 'noop.js'))
|
||||
|
||||
let pass = 0, fail = 0
|
||||
const check = (label, cond, detail = '') => {
|
||||
if (cond) { pass++; console.log(' ✓', label) }
|
||||
else { fail++; console.log(' ✗', label, detail) }
|
||||
}
|
||||
|
||||
console.log('### apes-sidebar 挂载验收')
|
||||
|
||||
// 1. 包可解析
|
||||
try {
|
||||
const pkgPath = req.resolve('apes-sidebar/package.json')
|
||||
const pkg = req('apes-sidebar/package.json')
|
||||
check('package.json 可解析', true, pkgPath)
|
||||
check('name=apes-sidebar', pkg.name === 'apes-sidebar')
|
||||
check('version=2.0.0', pkg.version === '2.0.0')
|
||||
} catch (e) {
|
||||
check('package.json 可解析', false, e.message)
|
||||
}
|
||||
|
||||
// 2. host entry 加载 + shape
|
||||
try {
|
||||
const host = req('apes-sidebar')
|
||||
check('host entry 加载成功', true)
|
||||
check('导出 name (string)', typeof host.name === 'string', 'got ' + typeof host.name)
|
||||
check('导出 inject (string[])', Array.isArray(host.inject), 'got ' + typeof host.inject)
|
||||
check('inject === [] (无硬依赖 service)', JSON.stringify(host.inject) === '[]', JSON.stringify(host.inject))
|
||||
check('导出 apply (function)', typeof host.apply === 'function')
|
||||
check('无 default export(namespace 规则)', !('default' in host))
|
||||
console.log(' name =', JSON.stringify(host.name))
|
||||
} catch (e) {
|
||||
check('host entry 加载', false, e.message)
|
||||
}
|
||||
|
||||
// 3. invariant surface
|
||||
try {
|
||||
const inv = req('apes-sidebar/invariant')
|
||||
check('invariant 可加载', true)
|
||||
check('invariant.assert 是函数', typeof inv.assert === 'function')
|
||||
} catch (e) {
|
||||
check('invariant 可加载', false, e.message)
|
||||
}
|
||||
|
||||
// 4. client entry 存在 + 是 browser artifact
|
||||
try {
|
||||
const clientPath = req.resolve('apes-sidebar/client')
|
||||
const clientSrc = fs.readFileSync(clientPath, 'utf8')
|
||||
check('client entry 文件存在', true, clientPath)
|
||||
check('client 是 __ModuleLoader__ bundle(line 1 window)',
|
||||
clientSrc.split('\n')[0].includes('window.__ModuleLoader__.load'),
|
||||
'line1=' + clientSrc.split('\n')[0].slice(0, 50))
|
||||
} catch (e) {
|
||||
check('client entry', false, e.message)
|
||||
}
|
||||
|
||||
// 5. peer dep dsh-better-sidebar 版本满足 ^0.18.0
|
||||
try {
|
||||
const bs = req('dsh-better-sidebar/package.json')
|
||||
const v = bs.version
|
||||
check('dsh-better-sidebar 版本满足 ^0.18.0',
|
||||
v === '0.18.0', 'got ' + v)
|
||||
} catch (e) {
|
||||
check('dsh-better-sidebar 版本', false, e.message)
|
||||
}
|
||||
|
||||
console.log('\n=== 验收结果: ' + pass + ' passed, ' + fail + ' failed ===')
|
||||
process.exit(fail > 0 ? 1 : 0)
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
|
||||
* sessions created by the model through the `terminal_create` tool. Each
|
||||
* handle survives across tool calls (and across WebSocket disconnects from
|
||||
* the sidebar view) until the model calls `terminal_close` or the user
|
||||
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
|
||||
* session.
|
||||
*
|
||||
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
|
||||
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
|
||||
* keyed by uuid and uncapped (the model is trusted to close unused ones).
|
||||
* Both registries share the same shell resolver and spawn-helper fix.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { ensureSpawnHelper } from './pty-manager.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
||||
const TRANSCRIPT_LIMIT = 1 << 20
|
||||
|
||||
/** POSIX signals the registry forwards to a live pty. */
|
||||
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
|
||||
/** Signal name accepted by `signal()`. */
|
||||
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
|
||||
|
||||
/** Default read page size (lines) when the caller omits `count`. */
|
||||
export const DEFAULT_READ_COUNT = 500
|
||||
|
||||
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MIN = 2
|
||||
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MAX = 1024
|
||||
|
||||
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
||||
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
|
||||
const clamp = (value: number): number =>
|
||||
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
|
||||
return { cols: clamp(cols), rows: clamp(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable snapshot of one agent terminal — the shape the model sees
|
||||
* through `terminal_list` and the sidebar sees through the push endpoint.
|
||||
* Carries no pty reference and no transcript (those are reached through
|
||||
* dedicated read/attach paths), and no sessionId: ownership is registry
|
||||
* internals, scoped by the caller (list filters by session, the push
|
||||
* endpoint scopes by its query param), never part of the serialized view.
|
||||
*/
|
||||
export interface AgentTerminalSnapshot {
|
||||
/** Stable opaque handle the model passes back to other terminal_* tools. */
|
||||
uuid: string
|
||||
/** Display title the model chose at create time. */
|
||||
title: string
|
||||
/** The command the model asked to run at create time (verbatim). */
|
||||
command: string
|
||||
/** Whether the top-level process has exited. */
|
||||
exited: boolean
|
||||
/** Exit code if exited normally; absent until the process exits. */
|
||||
exitCode?: number | null
|
||||
/** Exit signal name if the process was killed by a signal; null otherwise. */
|
||||
exitSignal?: string | null
|
||||
}
|
||||
|
||||
/** Map a POSIX signal number to its conventional name (best-effort). */
|
||||
const SIGNAL_NAMES: Record<number, string> = {
|
||||
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
|
||||
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
|
||||
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
|
||||
}
|
||||
|
||||
/** Convert a raw signal number to a name (or null when absent/unknown). */
|
||||
function signalNameOf(signal: number | null | undefined): string | null {
|
||||
if (signal === null || signal === undefined) return null
|
||||
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
|
||||
}
|
||||
|
||||
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
||||
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
|
||||
if (needle === '') return undefined
|
||||
const idx = transcript.indexOf(needle)
|
||||
if (idx === -1) return undefined
|
||||
// Walk the transcript up to the match index, counting newlines to derive
|
||||
// the 0-based line; the column is the offset within that line.
|
||||
let line = 0
|
||||
let lineStart = 0
|
||||
for (let i = 0; i < idx; i += 1) {
|
||||
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
|
||||
line += 1
|
||||
lineStart = i + 1
|
||||
}
|
||||
}
|
||||
return { line, column: idx - lineStart }
|
||||
}
|
||||
|
||||
/** One live agent terminal. */
|
||||
export interface AgentTerminalHandle {
|
||||
/** Stable opaque handle. */
|
||||
uuid: string
|
||||
/** Owning conversation id. */
|
||||
sessionId: string
|
||||
/** Display title. */
|
||||
title: string
|
||||
/** The command written to stdin right after spawn. */
|
||||
command: string
|
||||
/** The working directory the process was spawned with. */
|
||||
cwd: string
|
||||
/** The live pty process. */
|
||||
pty: nodePty.IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
exited: boolean
|
||||
/** Exit code once known. */
|
||||
exitCode?: number | null
|
||||
/** Exit signal number once known (POSIX only; undefined on Windows). */
|
||||
exitSignal?: number | null
|
||||
}
|
||||
|
||||
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
|
||||
export interface AgentTerminalReadResult {
|
||||
/** The slice of transcript text for the requested page. */
|
||||
text: string
|
||||
/** Total lines in the retained transcript (the page may be a subset). */
|
||||
totalLines: number
|
||||
/** 0-based index of the first line in `text` (inclusive). */
|
||||
lineBegin: number
|
||||
/** 0-based index of the last line in `text` (exclusive). */
|
||||
lineEnd: number
|
||||
}
|
||||
|
||||
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
||||
export type AgentTerminalWaitResult =
|
||||
| {
|
||||
/** The needle was found in the transcript. */
|
||||
kind: 'found'
|
||||
/** The matched substring. */
|
||||
needle: string
|
||||
/** 0-based line index (in the retained transcript) where the needle first appeared. */
|
||||
line: number
|
||||
/** 0-based column index within that line where the match starts. */
|
||||
column: number
|
||||
/** Elapsed wall-clock milliseconds from the wait start to the match. */
|
||||
elapsedMs: number
|
||||
}
|
||||
| {
|
||||
/** The needle did not appear before the timeout. */
|
||||
kind: 'timeout'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
/** The configured timeout in milliseconds. */
|
||||
timeoutMs: number
|
||||
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
|
||||
totalLines: number
|
||||
}
|
||||
| {
|
||||
/** The terminal exited before the needle appeared. */
|
||||
kind: 'exited'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
/** The exit code, if known. */
|
||||
exitCode?: number | null
|
||||
/** The exit signal name, if the process was killed by a signal. */
|
||||
exitSignal?: string | null
|
||||
}
|
||||
|
||||
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
||||
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
||||
const out: AgentTerminalSnapshot = {
|
||||
uuid: handle.uuid,
|
||||
title: handle.title,
|
||||
command: handle.command,
|
||||
exited: handle.exited,
|
||||
}
|
||||
if (handle.exited) {
|
||||
out.exitCode = handle.exitCode ?? null
|
||||
out.exitSignal = signalNameOf(handle.exitSignal)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent terminal registry. The constructor takes the resolved shell
|
||||
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
|
||||
* spawn-helper chmod fix once at construction so the first agent terminal
|
||||
* does not race a lazy fixer.
|
||||
*/
|
||||
export class AgentPtyRegistry {
|
||||
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly shell: string) {
|
||||
ensureSpawnHelper()
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one agent terminal: start the shell in `cwd`, then write
|
||||
* `command + '\n'` to stdin so the command runs in the fresh shell. The
|
||||
* terminal stays alive after the command exits — the model can send more
|
||||
* input through `terminal_send` until it calls `terminal_close` or the
|
||||
* user closes the sidebar tab. An empty `command` spawns a bare shell.
|
||||
* @returns the new handle's uuid (the model-facing opaque id).
|
||||
*/
|
||||
create(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
command: string,
|
||||
cwd: string,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
})
|
||||
const handle: AgentTerminalHandle = {
|
||||
uuid,
|
||||
sessionId,
|
||||
title,
|
||||
command,
|
||||
cwd,
|
||||
pty,
|
||||
transcript: '',
|
||||
exited: false,
|
||||
}
|
||||
pty.onData((data) => {
|
||||
handle.transcript += data
|
||||
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
||||
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
||||
}
|
||||
})
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
handle.exited = true
|
||||
handle.exitCode = exitCode
|
||||
handle.exitSignal = signal
|
||||
this.notify()
|
||||
})
|
||||
if (command !== '') {
|
||||
// Write the command + Enter so it runs in the freshly spawned shell.
|
||||
// Use \r (carriage return) — the actual character a terminal sends for
|
||||
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
|
||||
// soft line break (continuation prompt ">>") rather than a command
|
||||
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
|
||||
try {
|
||||
pty.write(`${command}\r`)
|
||||
} catch {
|
||||
// A spawn that failed between onData and onExit surfaces its own
|
||||
// exit; the create call still returns the uuid so the model can
|
||||
// read the transcript and see the failure.
|
||||
}
|
||||
}
|
||||
this.sessions.set(uuid, handle)
|
||||
this.notify()
|
||||
return uuid
|
||||
}
|
||||
|
||||
/** All live agent terminals belonging to one conversation. */
|
||||
list(sessionId: string): AgentTerminalSnapshot[] {
|
||||
const out: AgentTerminalSnapshot[] = []
|
||||
for (const handle of this.sessions.values()) {
|
||||
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve a live handle by uuid, or throw `not-found`. */
|
||||
private expect(uuid: string): AgentTerminalHandle {
|
||||
const handle = this.sessions.get(uuid)
|
||||
if (handle === undefined) {
|
||||
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
||||
* The model-facing tools call this before every uuid-keyed operation: a
|
||||
* uuid from another session is indistinguishable from an unknown one, so a
|
||||
* model can never reach (or probe) a terminal it does not own.
|
||||
*/
|
||||
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.sessionId !== sessionId) {
|
||||
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
||||
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
|
||||
const handle = this.sessions.get(uuid)
|
||||
return handle === undefined ? undefined : snapshotOf(handle)
|
||||
}
|
||||
|
||||
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
|
||||
send(uuid: string, text: string): void {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.exited) {
|
||||
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
|
||||
}
|
||||
handle.pty.write(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one bounded page of the retained transcript. `offset` is a 0-based
|
||||
* line index from the start of the retained transcript (default 0);
|
||||
* `count` caps the page size (default 500). A negative `offset` reads
|
||||
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
|
||||
* so the model can paginate.
|
||||
*/
|
||||
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
|
||||
const handle = this.expect(uuid)
|
||||
const lines = handle.transcript.split('\n')
|
||||
const totalLines = lines.length
|
||||
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
|
||||
let start: number
|
||||
if (offset === undefined || offset === 0) {
|
||||
start = 0
|
||||
} else if (offset < 0) {
|
||||
// Negative offset: read from the end (e.g. -50 → last 50 lines).
|
||||
start = Math.max(0, totalLines + offset)
|
||||
} else {
|
||||
start = Math.min(offset, totalLines)
|
||||
}
|
||||
const end = Math.min(start + pageSize, totalLines)
|
||||
const slice = lines.slice(start, end).join('\n')
|
||||
return {
|
||||
text: slice,
|
||||
totalLines,
|
||||
lineBegin: start,
|
||||
lineEnd: end,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
||||
* @returns the dimensions actually applied (the caller echoes these, so the
|
||||
* reported value always matches the pty).
|
||||
*/
|
||||
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
const handle = this.expect(uuid)
|
||||
const dims = clampDims(cols, rows)
|
||||
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
|
||||
return dims
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for `needle` to appear in a terminal's transcript, or for the
|
||||
* terminal to exit, or for the timeout to elapse — whichever happens
|
||||
* first. The wait polls the live transcript every ~50ms and short-circuits
|
||||
* on `signal` abort (re-thrown as the abort reason so the tool layer
|
||||
* surfaces cancellation).
|
||||
*
|
||||
* The match scans the FULL retained transcript on each poll, not just the
|
||||
* delta since the last poll — a needle that scrolled past the most recent
|
||||
* chunk but is still within the ~1 MiB bound is still a match. The
|
||||
* returned line/column locate the FIRST occurrence (oldest), which is what
|
||||
* a user watching the terminal would have seen first.
|
||||
*
|
||||
* The implementation uses polling (not pty onData subscription) because
|
||||
* node-pty's onData fires before the registry's own onData listener
|
||||
* updates the transcript (listener order is not guaranteed), and on
|
||||
* Windows ConPTY output can arrive in bursts with batching delays that
|
||||
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
||||
* interactive use and simple enough to be obviously correct.
|
||||
* @param uuid - terminal to watch.
|
||||
* @param needle - substring to search for (case-sensitive, verbatim).
|
||||
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
||||
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
||||
* @returns one of `found` / `timeout` / `exited`.
|
||||
*/
|
||||
async waitFor(
|
||||
uuid: string,
|
||||
needle: string,
|
||||
timeoutMs = 10_000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentTerminalWaitResult> {
|
||||
if (needle === '') {
|
||||
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
|
||||
}
|
||||
const handle = this.expect(uuid)
|
||||
const timeout = Math.max(100, Math.floor(timeoutMs))
|
||||
const start = Date.now()
|
||||
const deadline = start + timeout
|
||||
// Fast path: already exited, or the needle is already in the transcript
|
||||
// (a `terminal_send` may have produced the expected output before this
|
||||
// call even started).
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
const firstHit = locateNeedle(handle.transcript, needle)
|
||||
if (firstHit !== undefined) {
|
||||
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
|
||||
}
|
||||
// Poll loop: check the transcript every 50ms, exit on match / exit /
|
||||
// abort / timeout. The handle is read live each iteration (its transcript
|
||||
// and exited fields mutate as the pty produces output).
|
||||
while (true) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
const hit = locateNeedle(handle.transcript, needle)
|
||||
if (hit !== undefined) {
|
||||
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
const t = setTimeout(resolve, 50)
|
||||
// Allow the Node process to exit even if the timer is pending.
|
||||
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a POSIX signal to a terminal's foreground process.
|
||||
*
|
||||
* Two delivery paths, by signal kind:
|
||||
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
|
||||
* writing the corresponding control character to the pty stdin. This is
|
||||
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
|
||||
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
|
||||
* (Windows), which translates it into a SIGINT/SIGTSTP for the
|
||||
* foreground process group. This works on every platform — calling
|
||||
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
|
||||
* but writing `\x03` is universally correct.
|
||||
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
|
||||
* which maps to the platform's process-termination path (POSIX
|
||||
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
|
||||
* control characters.
|
||||
*/
|
||||
signal(uuid: string, signal: AgentTerminalSignal): void {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.exited) return
|
||||
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
|
||||
// master is the cross-platform way to deliver the signal to the
|
||||
// foreground process group through the terminal's input pipeline.
|
||||
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
|
||||
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
|
||||
try {
|
||||
handle.pty.write(ctrlByte)
|
||||
} catch {
|
||||
// A pty that rejects writes is already tearing down; the next
|
||||
// onExit will mark it exited. Not an error from the caller's view.
|
||||
}
|
||||
return
|
||||
}
|
||||
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
|
||||
try {
|
||||
handle.pty.kill(signal)
|
||||
} catch {
|
||||
// node-pty on Windows rejects named signals other than the default;
|
||||
// fall back to the default kill (TerminateProcess on Windows,
|
||||
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
|
||||
try {
|
||||
handle.pty.kill()
|
||||
} catch {
|
||||
// Already exited or gone; nothing left to kill.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal and drop its state. Idempotent: a second close of the
|
||||
* same uuid is a no-op. Returns true iff a live handle was actually
|
||||
* dropped.
|
||||
*/
|
||||
close(uuid: string): boolean {
|
||||
const handle = this.sessions.get(uuid)
|
||||
if (handle === undefined) return false
|
||||
this.sessions.delete(uuid)
|
||||
try {
|
||||
handle.pty.kill()
|
||||
} catch {
|
||||
// Already exited or gone; nothing left to kill.
|
||||
}
|
||||
this.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Resolve a live handle by uuid (for the WS attach path). */
|
||||
get(uuid: string): AgentTerminalHandle | undefined {
|
||||
return this.sessions.get(uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registry changes (create / close / exit). The sidebar push
|
||||
* endpoint uses this to forward snapshots to the connected view. Returns
|
||||
* the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener)
|
||||
return () => { this.changeListeners.delete(listener) }
|
||||
}
|
||||
|
||||
/** Close every agent terminal (plugin teardown). */
|
||||
disposeAll(): void {
|
||||
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
|
||||
}
|
||||
|
||||
/** Fire every change listener (callers wrap in try/catch if needed). */
|
||||
private notify(): void {
|
||||
for (const listener of [...this.changeListeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch {
|
||||
// A listener throwing must not break the others or the registry.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The diff tab: one change opened from the git panel, like VSCode's diff
|
||||
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
|
||||
* not; untracked files — which git diff never covers — render as a full-file
|
||||
* addition from their content), a commit ref loads the commit's full patch
|
||||
* (`git.show`-style). The header carries a refresh button because the tab
|
||||
* stays mounted while the git panel's staging/discard operations change the
|
||||
* very content it shows.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffView } from './DiffView.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The loaded diff surface (untracked content rendered as a full addition). */
|
||||
interface DiffData {
|
||||
diff: string
|
||||
untracked?: string
|
||||
}
|
||||
|
||||
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
|
||||
const { sessionId, cwd, diff } = props
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const scope: SessionScope = { sessionId, cwd }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setData(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
||||
if (result.diff === '') {
|
||||
// The requested side is empty — try the OTHER side once: the ref
|
||||
// may predate the staged-flag fix, or the change moved sides (a
|
||||
// file staged after its tab opened). Both sides empty means the
|
||||
// file genuinely has no text changes.
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
||||
if (other.diff !== '') result = other
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
// Empty diff: an untracked file (git diff never lists it) falls back
|
||||
// to a full-file addition; anything else is a genuine no-text-change.
|
||||
if (diff.untracked === true && !diff.staged) {
|
||||
const text = await api.fsRead(scope, diff.path)
|
||||
if (!cancelled) {
|
||||
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!cancelled) setData({ diff: '' })
|
||||
} catch (reason) {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, cwd, diff, tick])
|
||||
|
||||
return (
|
||||
<div className={css.gitDiffTab}>
|
||||
<div className={css.gitDiffTabHeader}>
|
||||
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
|
||||
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
|
||||
{!loading && error === null && data !== null && (
|
||||
<>
|
||||
{data.untracked !== undefined
|
||||
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
||||
: <DiffView diff={data.diff} />}
|
||||
{data.diff === '' && data.untracked === undefined && (
|
||||
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* The real diff surface for the git panel: parses the host's unified diff
|
||||
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
|
||||
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
|
||||
* gutters, and aligned context / deleted / added rows colored through the
|
||||
* DSH tokens. Untracked files produce no `git diff` output, so the caller
|
||||
* can pass the file content to render as a full-file addition instead.
|
||||
*
|
||||
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
||||
* cases are unit-tested without a DOM.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One rendered diff line. */
|
||||
export interface DiffLine {
|
||||
kind: 'ctx' | 'del' | 'add' | 'meta'
|
||||
/** The line content without its diff marker ('' for the no-newline marker). */
|
||||
text: string
|
||||
/** Old-side line number (null for pure additions / metadata). */
|
||||
oldNum: number | null
|
||||
/** New-side line number (null for pure deletions / metadata). */
|
||||
newNum: number | null
|
||||
}
|
||||
|
||||
/** One parsed hunk. */
|
||||
export interface DiffHunk {
|
||||
/** The old-side start line (`-a[,b]`). */
|
||||
oldStart: number
|
||||
/** The new-side start line (`+c[,d]`). */
|
||||
newStart: number
|
||||
/** The section text after the trailing `@@` (may be empty). */
|
||||
header: string
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
/** One parsed file section of a unified diff. */
|
||||
export interface DiffFile {
|
||||
/** The `---` path verbatim ('/dev/null' for a new file). */
|
||||
oldPath: string
|
||||
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
|
||||
newPath: string
|
||||
/** The file changed with binary content: no hunks to draw. */
|
||||
binary: boolean
|
||||
hunks: DiffHunk[]
|
||||
}
|
||||
|
||||
/** The parsed unified diff. */
|
||||
export interface ParsedDiff {
|
||||
files: DiffFile[]
|
||||
}
|
||||
|
||||
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
|
||||
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
|
||||
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
|
||||
if (match === null) return null
|
||||
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --no-color` output into file sections and hunks. Rows
|
||||
* outside a file section (leading noise) and metadata rows between the
|
||||
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
|
||||
* changes, rename/similarity lines) are skipped; a section that never
|
||||
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
|
||||
* can still draw its path.
|
||||
*/
|
||||
export function parseUnifiedDiff(text: string): ParsedDiff {
|
||||
const files: DiffFile[] = []
|
||||
let current: DiffFile | null = null
|
||||
let inHunk = false
|
||||
let hunk: DiffHunk | null = null
|
||||
let oldNum = 0
|
||||
let newNum = 0
|
||||
const flushHunk = (): void => {
|
||||
if (current !== null && hunk !== null) current.hunks.push(hunk)
|
||||
hunk = null
|
||||
inHunk = false
|
||||
}
|
||||
for (const raw of text.split('\n')) {
|
||||
if (raw.startsWith('diff --git ')) {
|
||||
flushHunk()
|
||||
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
|
||||
files.push(current)
|
||||
continue
|
||||
}
|
||||
if (current === null) continue
|
||||
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
|
||||
flushHunk()
|
||||
current.binary = true
|
||||
continue
|
||||
}
|
||||
if (raw.startsWith('--- ')) {
|
||||
flushHunk()
|
||||
current.oldPath = raw.slice(4)
|
||||
continue
|
||||
}
|
||||
if (raw.startsWith('+++ ')) {
|
||||
current.newPath = raw.slice(4)
|
||||
continue
|
||||
}
|
||||
const header = parseHunkHeader(raw)
|
||||
if (header !== null) {
|
||||
flushHunk()
|
||||
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
|
||||
oldNum = header.oldStart
|
||||
newNum = header.newStart
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if (!inHunk || hunk === null) continue
|
||||
const marker = raw[0]
|
||||
if (marker === '\\') {
|
||||
// `\ No newline at end of file`: metadata attached to the previous row.
|
||||
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
|
||||
continue
|
||||
}
|
||||
if (marker === ' ') {
|
||||
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
|
||||
oldNum += 1
|
||||
newNum += 1
|
||||
} else if (marker === '-') {
|
||||
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
|
||||
oldNum += 1
|
||||
} else if (marker === '+') {
|
||||
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
|
||||
newNum += 1
|
||||
} else {
|
||||
// Not a diff line (a hunk can never contain one): stop the hunk.
|
||||
flushHunk()
|
||||
}
|
||||
}
|
||||
flushHunk()
|
||||
return { files }
|
||||
}
|
||||
|
||||
/** Build the untracked-file shape: one file, one hunk of pure additions. */
|
||||
function untrackedFile(path: string, content: string): DiffFile {
|
||||
const lines: DiffLine[] = []
|
||||
const body = content.endsWith('\n') ? content.slice(0, -1) : content
|
||||
if (body !== '') {
|
||||
let num = 1
|
||||
for (const line of body.split('\n')) {
|
||||
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
|
||||
num += 1
|
||||
}
|
||||
}
|
||||
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
||||
}
|
||||
|
||||
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
||||
function displayPath(path: string): string {
|
||||
if (path === '/dev/null') return path
|
||||
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
||||
return path
|
||||
}
|
||||
|
||||
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
||||
function fileTag(file: DiffFile): string | null {
|
||||
if (file.binary) return t('diffBinary')
|
||||
if (file.oldPath === '/dev/null') return t('diffAdded')
|
||||
if (file.newPath === '/dev/null') return t('diffDeleted')
|
||||
const oldPath = displayPath(file.oldPath)
|
||||
const newPath = displayPath(file.newPath)
|
||||
if (oldPath !== newPath) return t('diffRenamed')
|
||||
return null
|
||||
}
|
||||
|
||||
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
||||
const MAX_DIFF_ROWS = 500
|
||||
|
||||
export interface DiffViewProps {
|
||||
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
||||
diff: string
|
||||
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
||||
untrackedPath?: string
|
||||
untrackedContent?: string
|
||||
}
|
||||
|
||||
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
|
||||
const parsed = useMemo<ParsedDiff>(() => {
|
||||
if (untrackedPath !== undefined) {
|
||||
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
||||
}
|
||||
return parseUnifiedDiff(diff)
|
||||
}, [diff, untrackedPath, untrackedContent])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Flatten into display rows so the cap can slice a single list.
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
parsed.files.forEach((file, fileIndex) => {
|
||||
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
||||
if (file.binary) return
|
||||
file.hunks.forEach((hunk, hunkIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
||||
hunk.lines.forEach((line, lineIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
||||
})
|
||||
})
|
||||
})
|
||||
return out
|
||||
}, [parsed])
|
||||
|
||||
const hidden = rows.length - MAX_DIFF_ROWS
|
||||
const capped = hidden > 0 && !expanded
|
||||
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
|
||||
const tailLines = MAX_DIFF_ROWS - headLines
|
||||
const head = capped ? rows.slice(0, headLines) : rows
|
||||
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
||||
if (row.type === 'path') {
|
||||
const tag = fileTag(row.file)
|
||||
const from = displayPath(row.file.oldPath)
|
||||
const to = displayPath(row.file.newPath)
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffFile}>
|
||||
<span className={css.gitDiffFilePath}>{to}</span>
|
||||
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
||||
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.type === 'hunk') {
|
||||
const hunk = row.hunk!
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffHunk}>
|
||||
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
|
||||
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const line = row.line!
|
||||
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
||||
return (
|
||||
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
|
||||
{line.kind === 'meta'
|
||||
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
||||
: (
|
||||
<>
|
||||
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
||||
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
||||
<span className={css.gitDiffCode}>{line.text}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.gitDiff}>
|
||||
{head.map(renderRow)}
|
||||
{hidden > 0 && (
|
||||
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
|
||||
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
{tail.map(renderRow)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
*
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
||||
const apply = (action: EditorLoadAction): void => {
|
||||
if (cancelled) return
|
||||
switch (action.kind) {
|
||||
case 'binary':
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
case 'render':
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
viewer: action.viewer,
|
||||
content: action.content,
|
||||
truncated: action.truncated,
|
||||
mediaUrl: action.mediaUrl,
|
||||
customData: action.customData,
|
||||
})
|
||||
return
|
||||
case 'customLoad':
|
||||
void action.viewer.load?.(path, scope).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
case 'fetchFsRead':
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
// Binary reads carry the head bytes for the detect re-match.
|
||||
const outcome = planFsReadOutcome(action.viewer, {
|
||||
binary: result.kind === 'binary',
|
||||
content: result.kind === 'text' ? result.content : '',
|
||||
truncated: result.truncated,
|
||||
head: result.kind === 'binary' ? result.head : undefined,
|
||||
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
||||
apply(outcome)
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The file explorer: a lazy VSCode-style tree rooted at the session's
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
*
|
||||
* Row actions: hovering a row reveals an @-reference button on the far
|
||||
* right (appends `@<relative path>` to the composer draft), and right-click
|
||||
* opens a context menu to copy the relative or absolute path (with a brief
|
||||
* "copied" label replacing the button after a successful write); file rows
|
||||
* also offer a download action (the host serves raw bytes, binary-safe).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
entries?: FsEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
function baseName(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
setData(dataRef.current)
|
||||
}, [])
|
||||
|
||||
const loadDir = useCallback((dir: string) => {
|
||||
if (dataRef.current[dir] !== undefined) return
|
||||
storeLevel(dir, {})
|
||||
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
||||
storeLevel(dir, { entries: listing.entries })
|
||||
}).catch((error: unknown) => {
|
||||
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
}, [sessionId, cwd, storeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
// Load the visible set; already-loaded levels (kept in the cache) are
|
||||
// not refetched. Only the refresh button wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopiedPath(path)
|
||||
window.setTimeout(() => {
|
||||
setCopiedPath(current => current === path ? null : current)
|
||||
}, COPIED_MS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** The row's trailing actions: the @-reference button, or the copied label. */
|
||||
const rowActions = (entry: FsEntry): ReactNode => {
|
||||
if (copiedPath === entry.path) {
|
||||
return <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(entry.path)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
/** Download a file through the host route (raw bytes, binary-safe). */
|
||||
const downloadFile = (path: string): void => {
|
||||
const url = downloadUrl({ sessionId, cwd }, path)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
||||
}
|
||||
if (level.error !== undefined) {
|
||||
return (
|
||||
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
||||
{level.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
if (entry.isDir) {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorer}>
|
||||
<div className={css.explorerHeader}>
|
||||
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => {
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the explorer's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* The source-control panel: status list (staged vs unstaged), stage/unstage,
|
||||
* commit with a message box, branch switch, and a VSCode-like history — rows
|
||||
* carry branch decorations, author and relative time. Clicking a changed
|
||||
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
|
||||
* placed below the git pane on first use. File rows and history rows open a
|
||||
* right-click context menu with advanced operations (open in editor, discard,
|
||||
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
|
||||
* focus (no file watcher — KISS).
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
|
||||
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { relativeTime, t } from './locales.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The XY status letters a row badge shows (X = index, Y = worktree). */
|
||||
function badgeOf(entry: GitStatusEntry): string {
|
||||
const index = entry.xy[0]
|
||||
const worktree = entry.xy[1]
|
||||
if (index !== undefined && index !== ' ' && index !== '?') return index
|
||||
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
|
||||
return '?'
|
||||
}
|
||||
|
||||
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
|
||||
function isStagedEntry(entry: GitStatusEntry): boolean {
|
||||
const index = entry.xy[0]
|
||||
return index !== undefined && index !== ' ' && index !== '?'
|
||||
}
|
||||
|
||||
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
|
||||
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
|
||||
* with both letters set ('MM') lands in BOTH sections. */
|
||||
function isUnstagedEntry(entry: GitStatusEntry): boolean {
|
||||
if (entry.xy === '??') return true
|
||||
const worktree = entry.xy[1]
|
||||
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
|
||||
}
|
||||
|
||||
/** Whether the entry is untracked (`??`): git diff never includes it. */
|
||||
function isUntracked(entry: GitStatusEntry): boolean {
|
||||
return badgeOf(entry) === '?'
|
||||
}
|
||||
|
||||
/** The last path segment (tab title for a file's diff). */
|
||||
function baseName(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
|
||||
function refNames(refs: string): string[] {
|
||||
return [...new Set(
|
||||
refs
|
||||
.split(',')
|
||||
.map(ref => ref.trim())
|
||||
.filter(ref => ref !== '')
|
||||
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
|
||||
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
|
||||
)]
|
||||
}
|
||||
|
||||
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => Promise<unknown>
|
||||
}
|
||||
|
||||
/** History batch size: the log loads lazily in pages so a long history never
|
||||
* floods the panel at once (the end of the log is reached by paging). */
|
||||
const LOG_BATCH = 20
|
||||
|
||||
export function GitView(props: {
|
||||
scope: SessionScope
|
||||
onOpenFile: (path: string) => void
|
||||
/** Open a diff tab (the shell places it below the git pane on first use). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}) {
|
||||
const { scope, onOpenFile, onOpenDiff } = props
|
||||
const [status, setStatus] = useState<GitStatusResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [branchNames, setBranchNames] = useState<string[]>([])
|
||||
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
|
||||
const [commitMsg, setCommitMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [commitError, setCommitError] = useState<string | null>(null)
|
||||
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
|
||||
const [logEnded, setLogEnded] = useState(false)
|
||||
const [logLoadingMore, setLogLoadingMore] = useState(false)
|
||||
|
||||
/** The open file-row context menu (cursor position for the portaled Menu). */
|
||||
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
|
||||
/** The open history-row context menu. */
|
||||
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
|
||||
/** The pending destructive action awaiting confirmation. */
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statusResult, branchResult, logResult] = await Promise.all([
|
||||
api.gitStatus(scope),
|
||||
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
|
||||
// The first history page only; the rest arrives via "load more".
|
||||
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
|
||||
])
|
||||
setStatus(statusResult)
|
||||
setBranchNames(branchResult.names)
|
||||
setLogEntries(logResult)
|
||||
setLogEnded(logResult.length < LOG_BATCH)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
if (logLoadingMore || logEnded) return
|
||||
setLogLoadingMore(true)
|
||||
try {
|
||||
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
|
||||
setLogEntries(entries => [...entries, ...next])
|
||||
if (next.length < LOG_BATCH) setLogEnded(true)
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
} finally {
|
||||
setLogLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
|
||||
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
type: 'diff',
|
||||
title: baseName(entry.path),
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
|
||||
})
|
||||
}
|
||||
|
||||
/** The diff tab for one commit (one tab per commit). */
|
||||
const openCommitDiff = (entry: GitLogEntry): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:c:${entry.hashFull}`,
|
||||
type: 'diff',
|
||||
title: `${entry.hash} ${entry.subject}`,
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
|
||||
})
|
||||
}
|
||||
|
||||
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope, entry.path)
|
||||
else await api.gitStage(scope, entry.path)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stageAll = async (staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope)
|
||||
else await api.gitStage(scope)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commit = async (): Promise<void> => {
|
||||
const message = commitMsg.trim()
|
||||
if (message === '' || busy) return
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCommit(scope, message)
|
||||
setCommitMsg('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkout = async (branch: string): Promise<void> => {
|
||||
if (branch === status?.branch || busy) return
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCheckout(scope, branch)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one destructive operation after the confirm modal, then refresh. */
|
||||
const runConfirmed = (confirmState: ConfirmState): void => {
|
||||
setConfirm({ ...confirmState, onConfirm: async () => {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await confirmState.onConfirm()
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
} })
|
||||
}
|
||||
|
||||
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
|
||||
const copy = (text: string): void => {
|
||||
void writeClipboard(text)
|
||||
}
|
||||
|
||||
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
|
||||
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
|
||||
|
||||
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
|
||||
return (
|
||||
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitRowMain}
|
||||
title={entry.path}
|
||||
onClick={() => { openWorktreeDiff(entry, staged) }}
|
||||
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
|
||||
>
|
||||
<span className={css.gitBadge}>{badgeOf(entry)}</span>
|
||||
<span className={css.gitName}>{entry.path}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={staged ? t('unstage') : t('stage')}
|
||||
title={staged ? t('unstage') : t('stage')}
|
||||
disabled={busy}
|
||||
onClick={() => { void stageEntry(entry, staged) }}
|
||||
>
|
||||
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.git}>
|
||||
<div className={css.gitHeader}>
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={status?.branch ?? ''}
|
||||
onChange={(event) => { void checkout(event.target.value) }}
|
||||
disabled={busy || (status !== null && !status.isRepo)}
|
||||
>
|
||||
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
|
||||
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
|
||||
{!loading && status !== null && !status.isRepo && (
|
||||
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
|
||||
)}
|
||||
|
||||
{status !== null && status.isRepo && (
|
||||
<>
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('staged')} ({stagedEntries.length})</span>
|
||||
{stagedEntries.length > 0 && (
|
||||
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
|
||||
{t('unstageAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
||||
{stagedEntries.map(entry => renderEntry(entry, true))}
|
||||
</div>
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('unstaged')} ({unstagedEntries.length})</span>
|
||||
{unstagedEntries.length > 0 && (
|
||||
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
|
||||
{t('stageAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
||||
{unstagedEntries.map(entry => renderEntry(entry, false))}
|
||||
</div>
|
||||
|
||||
<div className={css.gitCommit}>
|
||||
<Input
|
||||
className={css.gitCommitInput}
|
||||
placeholder={t('commitPlaceholder')}
|
||||
value={commitMsg}
|
||||
disabled={busy}
|
||||
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitCommitButton}
|
||||
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
|
||||
onClick={() => { void commit() }}
|
||||
>
|
||||
{t('commit')}
|
||||
</button>
|
||||
</div>
|
||||
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
|
||||
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
|
||||
{logEntries.map(entry => (
|
||||
<div
|
||||
key={entry.hashFull}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={css.gitLogRow}
|
||||
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
|
||||
onClick={() => { openCommitDiff(entry) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openCommitDiff(entry)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
|
||||
>
|
||||
<span className={css.gitLogLine1}>
|
||||
<span className={css.gitLogHash}>{entry.hash}</span>
|
||||
<span className={css.gitLogSubject}>{entry.subject}</span>
|
||||
</span>
|
||||
<span className={css.gitLogLine2}>
|
||||
{refNames(entry.refs).map(ref => (
|
||||
<span key={ref} className={css.gitLogRef}>{ref}</span>
|
||||
))}
|
||||
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!logEnded && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitLogMore}
|
||||
disabled={logLoadingMore || busy}
|
||||
onClick={() => { void loadMoreLog() }}
|
||||
>
|
||||
{logLoadingMore ? t('loading') : t('loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The one shared file-row context menu, positioned at the right-click
|
||||
cursor (portal so the panel's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={fileMenu !== null}
|
||||
onClose={() => { setFileMenu(null) }}
|
||||
items={[
|
||||
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
|
||||
fileMenu?.staged === true
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
|
||||
...(fileMenu !== null && !isUntracked(fileMenu.entry)
|
||||
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
|
||||
: []),
|
||||
{ type: 'separator', id: 'sep1' },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = fileMenu
|
||||
if (target === null) return
|
||||
setFileMenu(null)
|
||||
if (id === 'open') {
|
||||
onOpenFile(target.entry.path)
|
||||
return
|
||||
}
|
||||
if (id === 'stage') {
|
||||
void stageEntry(target.entry, target.staged)
|
||||
return
|
||||
}
|
||||
if (id === 'discard') {
|
||||
runConfirmed({
|
||||
title: t('discardTitle'),
|
||||
description: t('discardDesc', { path: target.entry.path }),
|
||||
confirmLabel: t('discard'),
|
||||
onConfirm: () => api.gitDiscard(scope, target.entry.path),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'relative') {
|
||||
copy(relativeTo(scope.cwd ?? '', target.entry.path))
|
||||
return
|
||||
}
|
||||
if (id === 'absolute') copy(target.entry.path)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* The shared history-row context menu. */}
|
||||
<Menu
|
||||
open={historyMenu !== null}
|
||||
onClose={() => { setHistoryMenu(null) }}
|
||||
items={[
|
||||
{ id: 'view', label: t('viewCommitDiff') },
|
||||
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ type: 'separator', id: 'sep2' },
|
||||
{ id: 'revert', label: t('revertCommit'), danger: true },
|
||||
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = historyMenu
|
||||
if (target === null) return
|
||||
setHistoryMenu(null)
|
||||
if (id === 'view') {
|
||||
openCommitDiff(target.entry)
|
||||
return
|
||||
}
|
||||
if (id === 'copyShort') {
|
||||
copy(target.entry.hash)
|
||||
return
|
||||
}
|
||||
if (id === 'copyFull') {
|
||||
copy(target.entry.hashFull)
|
||||
return
|
||||
}
|
||||
if (id === 'copySubject') {
|
||||
copy(target.entry.subject)
|
||||
return
|
||||
}
|
||||
if (id === 'revert') {
|
||||
runConfirmed({
|
||||
title: t('revertTitle'),
|
||||
description: t('revertDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('revertCommit'),
|
||||
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'cherryPick') {
|
||||
runConfirmed({
|
||||
title: t('cherryPickTitle'),
|
||||
description: t('cherryPickDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('cherryPickCommit'),
|
||||
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
|
||||
})
|
||||
}
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* Destructive actions land here first: Cancel / Confirm. */}
|
||||
<Modal
|
||||
open={confirm !== null}
|
||||
onClose={() => { setConfirm(null) }}
|
||||
title={confirm?.title ?? ''}
|
||||
closeLabel={t('cancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const pending = confirm
|
||||
if (pending === null) return
|
||||
setConfirm(null)
|
||||
void pending.onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirm?.confirmLabel ?? ''}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Placeholder shown when a persisted tab's type is not registered (the
|
||||
* contributing plugin is not installed / not yet loaded). The tab is kept
|
||||
* in state so it can recover if the plugin loads later; the user can close
|
||||
* it through the tab bar's X button as usual. This is the graceful-
|
||||
* degradation path for the open tab-type set: {@link Sidebar} renders this
|
||||
* instead of crashing on an unknown type.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
|
||||
export function OrphanedTab(props: TabComponentProps): ReactNode {
|
||||
const { tab } = props
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
|
||||
</div>
|
||||
<div className={css.editorPlaceholder}>
|
||||
<span>{t('pluginNotLoaded')}</span>
|
||||
<code className={css.orphanedType}>{tab.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Browser-native PDF preview with an always-available download fallback. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const [load, setLoad] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; url: string }
|
||||
| { status: 'error'; message: string }
|
||||
>({ status: 'loading' })
|
||||
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const shieldRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let objectUrl: string | undefined
|
||||
setLoad({ status: 'loading' })
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
// A direct iframe navigation may download when an old host process or
|
||||
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
||||
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||
setLoad({ status: 'ready', url: objectUrl })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const block = (): void => {
|
||||
setInteractionBlocked(true)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
||||
}
|
||||
const unblock = (): void => {
|
||||
setInteractionBlocked(false)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
||||
}
|
||||
const blockForResize = (event: PointerEvent): void => {
|
||||
const target = event.target
|
||||
if (target instanceof Element
|
||||
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
// An iframe is a separate browsing context and otherwise swallows the
|
||||
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
||||
// remove it from hit-testing while a tab drag or pane resize is active.
|
||||
document.addEventListener('dragstart', block, true)
|
||||
document.addEventListener('dragend', unblock, true)
|
||||
document.addEventListener('drop', unblock, true)
|
||||
window.addEventListener('pointerdown', blockForResize, true)
|
||||
window.addEventListener('pointerup', unblock, true)
|
||||
window.addEventListener('pointercancel', unblock, true)
|
||||
window.addEventListener('blur', unblock)
|
||||
return () => {
|
||||
document.removeEventListener('dragstart', block, true)
|
||||
document.removeEventListener('dragend', unblock, true)
|
||||
document.removeEventListener('drop', unblock, true)
|
||||
window.removeEventListener('pointerdown', blockForResize, true)
|
||||
window.removeEventListener('pointerup', unblock, true)
|
||||
window.removeEventListener('pointercancel', unblock, true)
|
||||
window.removeEventListener('blur', unblock)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorPdf}>
|
||||
<div className={css.editorPdfToolbar}>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPdfStage}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'ready' && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
||||
src={load.url}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={shieldRef}
|
||||
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; count: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [slide, setSlide] = useState(0)
|
||||
const [navigating, setNavigating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
setLoad({ status: 'loading' })
|
||||
setSlide(0)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||
if (controller.signal.aborted) return
|
||||
const viewer = await PptxViewer.open(bytes, host, {
|
||||
renderMode: 'slide',
|
||||
fitMode: 'contain',
|
||||
lazyMedia: true,
|
||||
lazySlides: true,
|
||||
pdfjs: false,
|
||||
signal: controller.signal,
|
||||
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||
onSlideChange: (index) => {
|
||||
if (!controller.signal.aborted) setSlide(index)
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
viewerRef.current = viewer
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Partially initialized viewer — ignore disposal errors.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Already destroyed.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
const navigate = (target: number): void => {
|
||||
const viewer = viewerRef.current
|
||||
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||
if (next === slide) return
|
||||
setNavigating(true)
|
||||
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
}).catch((error: unknown) => {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}).finally(() => {
|
||||
setNavigating(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editorPptx} aria-label={title}>
|
||||
<div className={css.editorPptxToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||
onClick={() => { navigate(slide - 1) }}
|
||||
>
|
||||
{t('previousSlide')}
|
||||
</button>
|
||||
<span className={css.editorPptxPosition}>
|
||||
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||
onClick={() => { navigate(slide + 1) }}
|
||||
>
|
||||
{t('nextSlide')}
|
||||
</button>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPptxStage}>
|
||||
<div className={css.editorPptxHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping);
|
||||
* - a card's on/off state IS its visual state: enabled = brand border +
|
||||
* tinted fill + a check badge pinned to the far right of the top row,
|
||||
* disabled = neutral hairline + dimmed text;
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* opening a native Modal whose rows are the DSH settings-row recipe
|
||||
* (title/desc left + native accent checkbox, hairline separators);
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
.cardOn {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature. */
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardOn .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
.cardCheck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardOn .cardDesc {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The gear corner button: bottom-right of cards that declare related
|
||||
settings; the desc gets right padding so the ellipsis never runs under it. */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native settings row recipe (the width input row + popup rows). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup body: stacked native rows, trailing separator
|
||||
stripped by the trailing-child rule. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.percentInput {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (a toggle card), and
|
||||
* the default panel width as a percent of the window (number input row).
|
||||
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
|
||||
* external plugins alike), laid out in a responsive grid that wraps
|
||||
* several cards per row — icon + title + type id, clicked to toggle the
|
||||
* switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
|
||||
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
||||
* border + tinted fill + a check badge pinned to the card's far right),
|
||||
* disabled = neutral and dimmed. Features that declare `settings.toggles`
|
||||
* carry a gear corner button that opens a native Modal with the related
|
||||
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
|
||||
* when a subagent appears", the Terminal page's model terminal tools).
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values and the sidebar's consumption points (the + menu, derived
|
||||
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconPanelLeftOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
||||
|
||||
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
||||
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
||||
}
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value. */
|
||||
function textOf(value: string | (() => string) | undefined): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
/** Resolve a descriptor icon (ReactNode or size function). */
|
||||
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
||||
if (icon === undefined) return null
|
||||
return typeof icon === 'function' ? icon(size) : icon
|
||||
}
|
||||
|
||||
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
||||
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
}
|
||||
|
||||
/** Read one boolean pref by declarative key (missing = false). */
|
||||
function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
return (prefs as unknown as Record<string, boolean>)[key] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one native checkbox row
|
||||
* per declared toggle. Extracted so the rows are testable without opening
|
||||
* the Modal (the Modal portal renders only while open).
|
||||
*/
|
||||
export function FeatureSettingsRows(props: {
|
||||
toggles: readonly SidebarSettingToggle[]
|
||||
prefs: SidebarPrefs
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
}) {
|
||||
const { toggles, prefs, onToggle } = props
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
return (
|
||||
<label key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
aria-label={title}
|
||||
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
// load/unload — so a plain effect is enough; no external-store ceremony).
|
||||
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
||||
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
||||
useEffect(() => service.subscribe(() => {
|
||||
setTabs([...service.getTabs()].sort(tabOrder))
|
||||
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
||||
}), [service])
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
// Whether the user already wrote since mount: the mount read must not
|
||||
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
||||
// route must never silently revert a just-made change).
|
||||
const dirtyRef = useRef(false)
|
||||
// Serialize commits: a queued write must observe the previous write's
|
||||
// revision; a failed write must not poison the queue for later ones.
|
||||
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
// Sync the persisted document once on mount: the revision and the current
|
||||
// values (another tab may have changed them since the store hydrated).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.settingsGet().then((view) => {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
{ ...patch },
|
||||
revisionRef.current,
|
||||
)
|
||||
const next = parsePrefs(view.value)
|
||||
revisionRef.current = view.revision
|
||||
store.setPrefs(next)
|
||||
return next
|
||||
})
|
||||
// A failed commit must not poison the queue: later writes still run.
|
||||
inFlightRef.current = run.then(() => undefined, () => undefined)
|
||||
return run.then(
|
||||
(next) => ({ ok: true, prefs: next }),
|
||||
(caught) => {
|
||||
setError(messageOf(caught))
|
||||
return { ok: false, prefs }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
togglePref({ openByDefault: next })
|
||||
}
|
||||
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the
|
||||
* check badge sits at the far right, and a feature that declares related
|
||||
* settings carries a gear corner button opening its settings popup.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
desc: string
|
||||
icon?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: (next: boolean) => void
|
||||
/** A feature with declared related settings shows the gear corner button. */
|
||||
onOpenSettings?: () => void
|
||||
}) => {
|
||||
const hasSettings = props.onOpenSettings !== undefined
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={props.enabled}
|
||||
title={props.desc}
|
||||
onClick={() => { props.onToggle(!props.enabled) }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{props.desc}</span>
|
||||
</button>
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardGear}
|
||||
aria-label={`${props.title} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenTitle'),
|
||||
desc: t('settingsOpenDesc'),
|
||||
icon: <IconPanelLeftOutline16 size={16} />,
|
||||
enabled: prefs.openByDefault,
|
||||
onToggle,
|
||||
})}
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
grid; features declaring `settings.toggles` open their settings in
|
||||
the popup (gear corner button) instead of nested inline rows. */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 文件预览: one small card per registered file viewer. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The secondary settings popup: a feature's declared related settings
|
||||
as native checkbox rows (Modal chrome is the app's own). Mounted
|
||||
only while a feature is open — the Modal primitive runs hooks
|
||||
unconditionally, so a closed-but-mounted Modal would break SSR
|
||||
(and the renderToString spec) under the test dual-react split. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
closeLabel={t('close')}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* The sidebar shell: a fixed-position right panel portalled onto
|
||||
* document.body (the core AppFrame owns the left sidebar / center / details
|
||||
* columns and has no right-side hole for plugins), plus the edge toggle
|
||||
* button. The panel width drags from its left edge; the whole layout lives
|
||||
* in the per-session store, so switching conversations swaps the sidebar.
|
||||
*
|
||||
* The shell binds the workbench actions to the store and dispatches tab
|
||||
* content to the four views. New tabs come from the + menu (explorer / git /
|
||||
* terminal; editors open from the explorer).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import {
|
||||
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
return descriptor.component({
|
||||
ctx, store, scope, tab, visible, expanded,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
})
|
||||
}
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { ctx, store } = props
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
||||
)
|
||||
const current = sessionList.current
|
||||
|
||||
// Per-session sidebar state.
|
||||
const snapshot = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot(), [store]),
|
||||
)
|
||||
useEffect(() => { store.setSession(current) }, [current, store])
|
||||
|
||||
const state = snapshot.state
|
||||
const sessionId = snapshot.sessionId
|
||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||
|
||||
// While the session's header is still hydrating (or the session is blank),
|
||||
// the list summary may carry no cwd; ask the host once (it falls back to
|
||||
// the process cwd) so the explorer root and terminal cwd are real from
|
||||
// first paint instead of showing "no session".
|
||||
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setFetchedCwd(undefined)
|
||||
if (sessionId === undefined || summaryCwd !== undefined) return
|
||||
let cancelled = false
|
||||
api.sessionCwd({ sessionId })
|
||||
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
|
||||
.catch(() => { /* the explorer/git rows surface their own errors */ })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, summaryCwd])
|
||||
const cwd = summaryCwd ?? fetchedCwd
|
||||
|
||||
/**
|
||||
* Agent terminals push: subscribe to the host's live list of agent-owned
|
||||
* terminals for this session (created by the model through the
|
||||
* `terminal_create` tool). The host pushes a JSON array on every
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, store])
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Subagent tab type is enabled in settings,
|
||||
* open the panel (if collapsed) and focus the Subagent page
|
||||
* (single-instance: an existing tab is focused, never duplicated).
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// Panel width drag (left edge strip).
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
|
||||
// Layout push: the app shell gives up the panel's width while it is open
|
||||
// (0 while collapsed), so the conversation and input bar are squeezed
|
||||
// instead of covered. The margin is capped at the viewport so a stale
|
||||
// persisted width (e.g. fullscreen on a bigger window) can never crush
|
||||
// the app shell to zero. Dragging disables the layout transition.
|
||||
useEffect(() => {
|
||||
const width = snapshot.state?.panelOpen === true
|
||||
? Math.min(snapshot.state.width, window.innerWidth)
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
}, [snapshot.state?.panelOpen, snapshot.state?.width])
|
||||
useEffect(() => {
|
||||
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
||||
}, [draggingWidth])
|
||||
|
||||
|
||||
// Whether the panel currently fills the viewport (fullscreen expansion).
|
||||
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
|
||||
|
||||
const actions: WorkbenchActions = useMemo(() => ({
|
||||
closeTab: (paneId, tabId) => {
|
||||
// A closed terminal releases its pty immediately — including when its
|
||||
// socket is mid-reconnect, where the unmount close frame never reaches
|
||||
// the host and the process would hold the quota until the grace ends.
|
||||
// Agent terminals (tabId `agent:<uuid>`) close through a different
|
||||
// host route: the WS close frame is the primary path (sent by
|
||||
// TerminalView on unmount), and the agent-pty.close HTTP route is the
|
||||
// fallback when the WS is down.
|
||||
const current = store.getSnapshot().state
|
||||
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
|
||||
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
||||
store.reduce(s => closeTab(s, paneId, tabId))
|
||||
if (tab?.type === 'terminal') {
|
||||
if (isAgentTabId(tabId)) {
|
||||
const uuid = agentUuidOf(tabId)
|
||||
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
|
||||
} else if (sessionId !== undefined) {
|
||||
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
||||
}
|
||||
}
|
||||
},
|
||||
activateTab: (paneId, tabId) => {
|
||||
store.reduce(s => ({
|
||||
...s,
|
||||
activePane: paneId,
|
||||
splits: mapLeaf(s.splits, paneId, (leaf) => {
|
||||
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
|
||||
}),
|
||||
}))
|
||||
},
|
||||
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
|
||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
||||
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
|
||||
},
|
||||
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
|
||||
store.reduce((s) => {
|
||||
let index = -1
|
||||
const source = leafWithTab(s.splits, beforeTabId)
|
||||
if (source !== undefined && source.id === toPane) {
|
||||
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
|
||||
}
|
||||
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
|
||||
})
|
||||
},
|
||||
resizeSplit: (splitId, index, deltaFrac) => {
|
||||
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). The conversation service is
|
||||
* resolved lazily through `ctx.get` (the inject-free read — the app's own
|
||||
* plugins read 'conversation' the same way); a missing service or scope
|
||||
* degrades to a logged no-op, never a crash. Defined above the no-session
|
||||
* early return — a hook must never sit behind a conditional return
|
||||
* (React counts hooks per render).
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string): void => {
|
||||
if (sessionId === undefined) return
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return
|
||||
const input = conversation.input.for(actx)
|
||||
const mention = `@${relativeTo(cwd ?? '', path)}`
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] reference insert failed:', error)
|
||||
}
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div className={css.toggleRail}>
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelLeftOutline16 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onNewTab = (optionId: string): void => {
|
||||
const service = ctx.betterSidebar
|
||||
const descriptor = service?.getTab(optionId)
|
||||
if (descriptor === undefined) return
|
||||
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
service.openTab({ type: optionId, title })
|
||||
}
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). Resolves the session-scope
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
/**
|
||||
* Render one tab's content. `active` (from the workbench) tells whether
|
||||
* this tab is the active one in its pane; combined with the panel's
|
||||
* open/closed state it gates live views (the Subagent topology pauses its
|
||||
* polling while the page is not actually visible). The pane id travels
|
||||
* with the tab so diff tabs can split below their source pane.
|
||||
*/
|
||||
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={state.expanded}
|
||||
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
||||
onReferenceFile={referenceInChat}
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
visible={state.panelOpen && active}
|
||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!state.panelOpen && (
|
||||
<div className={css.toggleRail}>
|
||||
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconPanelLeftOutline16 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
The panel stays mounted while collapsed (hidden off-screen) so the
|
||||
slide in/out can animate; visibility hides it after the slide settles.
|
||||
*/}
|
||||
<div
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: Math.min(state.width, window.innerWidth) }}
|
||||
data-dragging={draggingWidth || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
||||
setDraggingWidth(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
/>
|
||||
<div className={css.panelHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
||||
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
||||
onClick={() => {
|
||||
const viewport = window.innerWidth
|
||||
store.reduce(s => setWidth(s, fullscreen
|
||||
? Math.max(280, Math.round((viewport - 320) / 2))
|
||||
: viewport))
|
||||
}}
|
||||
>
|
||||
<IconFullscreenOutline16 />
|
||||
</button>
|
||||
<span className={css.panelTitle}>
|
||||
{t('explorer')}
|
||||
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('collapse')}
|
||||
title={t('collapse')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={(tab) => {
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) return null
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* Subagent page: main-agent topology tree. Row/card chrome mirrors the
|
||||
official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8,
|
||||
flex-start alignment, tree connector lines via border-left hairlines). */
|
||||
.subagent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subagentHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.subagentTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The descendant count badge ("3 · 1 运行中") beside the title. */
|
||||
.subagentCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.subagentRefresh {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentRefresh:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.subagentBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
/* One node card: state dot + label + secondary + (running) live lines. */
|
||||
.subagentRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 8px 7px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subagentRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Keyboard focus (mirror of the official catalog row): a visible fill only
|
||||
when focused via keyboard; the selected state below still wins on top. */
|
||||
.subagentRow:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The currently-open session, highlighted in place (mirror of the workspace
|
||||
`.sessionRow.selected` — declared after the hover/focus rules so it wins). */
|
||||
.subagentRowActive,
|
||||
.subagentRowActive:hover,
|
||||
.subagentRowActive:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.subagentRowDisabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.subagentRowDisabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Loading placeholders are disabled but keep the default cursor (official
|
||||
`.loadingRow` distinction from `.disabled`). */
|
||||
.subagentRowLoading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The state dot aligns to the first text line (official catalog detail). */
|
||||
.subagentDot {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.subagentContent {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.subagentLabel,
|
||||
.subagentSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLabel {
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subagentSecondary {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* ── Live lines (running cards only) ──────────────────────────────────── */
|
||||
|
||||
.subagentLive {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subagentLiveTool {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.subagentLiveArgs {
|
||||
min-width: 0;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLiveText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* ── Tree connector lines (official catalog recipe) ────────────────────── */
|
||||
|
||||
.subagentNode {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subagentChildren {
|
||||
position: relative;
|
||||
margin-left: 18px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.subagentChildren::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -26px;
|
||||
height: 26px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren[aria-busy='true']::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode:last-child::before {
|
||||
bottom: auto;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode > .subagentRow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -4px;
|
||||
width: 14px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 16px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subagentEmptyHint {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.subagentError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.subagentErrorRetry {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentErrorRetry:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Subagent page: the FULL agent topology of the current tree's main session.
|
||||
*
|
||||
* The root is resolved by walking the durable parent chain upward from the
|
||||
* current session to the first non-subagent session — the MAIN session — and
|
||||
* every subagent under it shares this one topology view, no matter how deep
|
||||
* the current selection is (including a subagent transcript opened in the
|
||||
* main view). The main agent renders as the root node card (click it to jump
|
||||
* back to the main session), with its subagents hanging below it in clearly
|
||||
* LAYERED levels: tree connector lines (first level included) and per-level
|
||||
* indentation show the hierarchy, and the currently-open session is
|
||||
* highlighted in place. Every branch is expanded automatically (lazy
|
||||
* catalogs hydrate on demand and consume live membership while visible).
|
||||
*
|
||||
* Each node card carries live status (state dot, durable label, mode and
|
||||
* activity); while a child RUNS, its card additionally shows the LAST text
|
||||
* output and LAST tool call pulled from its history tail, auto-refreshing
|
||||
* every few seconds while the page is visible. Clicking a card jumps
|
||||
* straight into the child transcript (`openSubagent`); the page stays open
|
||||
* and the topology remains rooted at the main session.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
countSubagentDescendants,
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
|
||||
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSessionSummary[] {
|
||||
return Object.values(byId).filter(
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label of one catalog child: durable label, then summary title, then id. */
|
||||
function childLabel(
|
||||
entry: SidebarSubagentChildEntry,
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
): string {
|
||||
return entry.label ?? summary?.displayTitle ?? entry.id
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return t('subagentDiagCorrupt')
|
||||
case 'unsupported': return t('subagentDiagUnsupported')
|
||||
case 'unavailable': return t('subagentDiagUnavailable')
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line of one card: title · mode · activity (skips empty parts). */
|
||||
function cardSecondary(
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
entry: SidebarSubagentChildEntry,
|
||||
): string {
|
||||
return [
|
||||
summary?.displayTitle,
|
||||
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
|
||||
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** First `limit` characters with an ellipsis when truncated. */
|
||||
function preview(text: string, limit: number): string {
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text
|
||||
}
|
||||
|
||||
/** Collapse whitespace for the single-paragraph live-text preview. */
|
||||
function flatten(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
|
||||
function CatalogLoadingRows(props: {
|
||||
parentSessionId: string
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
}) {
|
||||
const { parentSessionId, byId, level } = props
|
||||
const children = directChildren(byId, parentSessionId)
|
||||
if (children.length === 0) {
|
||||
return <div className={css.subagentEmpty}>{t('loading')}</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label={t('loading')}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
|
||||
>
|
||||
<StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{t('loading')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live lines of one RUNNING subagent card: the last text output and the
|
||||
* last tool call of the child's history tail, refreshed every few seconds
|
||||
* while the page is visible. Idle cards render nothing (a quiet topology); a
|
||||
* running child with neither output yet reads "thinking…".
|
||||
*/
|
||||
function SubagentLiveLines(props: {
|
||||
ctx: Context
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: SidebarSubagentAddress['mode']
|
||||
running: boolean
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { ctx, parentSessionId, childSessionId, mode, running, active } = props
|
||||
const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const address = useMemo(
|
||||
() => ({ parentSessionId, childSessionId, mode }),
|
||||
[parentSessionId, childSessionId, mode],
|
||||
)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const response = await ctx.connection.api.subagents.history(
|
||||
{ ...address, maxMessages: 12 },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
setLive(lastActivity(response.result.value.events))
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last known lines.
|
||||
}
|
||||
}, [ctx, address])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
void load()
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, running, active])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
if (!running) return null
|
||||
if (live.text === undefined && live.tool === undefined) {
|
||||
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{live.tool !== undefined && (
|
||||
<span className={css.subagentLive}>
|
||||
<span className={css.subagentLiveTool}>{live.tool.name}</span>
|
||||
{live.tool.args !== '' && (
|
||||
<span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{live.text !== undefined && (
|
||||
<span className={css.subagentLiveText}>{flatten(live.text)}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RowsProps {
|
||||
parentSessionId: string
|
||||
catalog: SidebarSubagentCatalog | undefined
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
/** The currently-open session id (highlighted in the topology). */
|
||||
currentSessionId: string
|
||||
/** The page is visible (active tab + open panel): live polling pauses otherwise. */
|
||||
active: boolean
|
||||
ctx: Context
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
refresh: (parentSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Render one topology level; branches are always expanded (lazy catalogs). */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
|
||||
openChild, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
<CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
|
||||
)}
|
||||
{catalog?.state === 'error' && (
|
||||
<div className={css.subagentError}>
|
||||
<span>{catalog.error?.message ?? t('error')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentErrorRetry}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(catalog?.entries ?? []).map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
|
||||
title={diagnosticReason(entry)}
|
||||
>
|
||||
<StateDot state="error" className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{entry.id}</span>
|
||||
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const knownLeaf = !entry.hasChildren
|
||||
const summary = byId[entry.id]
|
||||
const label = childLabel(entry, summary)
|
||||
const secondary = cardSecondary(summary, entry)
|
||||
const childLoading = childCatalog === undefined
|
||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||
const address: SidebarSubagentAddress = {
|
||||
parentSessionId,
|
||||
childSessionId: entry.id,
|
||||
mode: entry.mode,
|
||||
}
|
||||
const current = entry.id === currentSessionId
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={`${label} ${secondary}`}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': true }}
|
||||
className={clsx(css.subagentRow, current && css.subagentRowActive)}
|
||||
onClick={() => { openChild(address) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChild(address)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={entry.activity === 'running' ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
<SubagentLiveLines
|
||||
ctx={ctx}
|
||||
parentSessionId={parentSessionId}
|
||||
childSessionId={entry.id}
|
||||
mode={entry.mode}
|
||||
running={entry.activity === 'running'}
|
||||
active={active}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
|
||||
{childCatalog === undefined
|
||||
? (
|
||||
<CatalogLoadingRows
|
||||
parentSessionId={entry.id}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
* (active tab + open panel), the client context, and an optional
|
||||
* jump-notify hook fired right before `openSubagent` (lets the sidebar
|
||||
* shell re-open the Subagent page after the conversation switch lands on
|
||||
* the child session).
|
||||
* @returns the main agent's topology tree, or the empty/error/loading states.
|
||||
*/
|
||||
export function SubagentView(props: {
|
||||
sessionId: string
|
||||
active: boolean
|
||||
ctx: Context
|
||||
onOpenChild?: (address: SidebarSubagentAddress) => void
|
||||
}) {
|
||||
const { sessionId, active, ctx, onOpenChild } = props
|
||||
const sessions = ctx.sessions
|
||||
|
||||
// The same list feed the official catalog consumes (byId lineage + the
|
||||
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
|
||||
// simply leave these surfaces empty — the page degrades to the empty state.
|
||||
const list = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
)
|
||||
const byId = list.byId
|
||||
const catalogs = list.subagentsByParent ?? {}
|
||||
|
||||
// The topology root: the main agent of the current session's tree.
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
const observedRef = useRef(new Set<string>())
|
||||
|
||||
const observe = useCallback((parentSessionId: string, open: boolean): void => {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
|
||||
if (open) observedRef.current.add(parentSessionId)
|
||||
else observedRef.current.delete(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
// While the page is visible the topology root consumes live membership; a
|
||||
// root change (switching to another main agent's tree) or the page hiding
|
||||
// (tab switched away / panel collapsed) releases everything observed.
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
observe(rootId, true)
|
||||
return () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}
|
||||
}, [rootId, active, observe, sessions])
|
||||
|
||||
// Every branch of the always-expanded topology consumes live membership
|
||||
// (add-only: a branch stays observed until the root changes or the page
|
||||
// hides, which releases the whole set via the root effect's cleanup).
|
||||
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
for (const id of branches) {
|
||||
if (!observedRef.current.has(id)) observe(id, true)
|
||||
}
|
||||
}, [branches, active, observe])
|
||||
|
||||
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}, [sessions])
|
||||
|
||||
const openChild = useCallback((address: SidebarSubagentAddress): void => {
|
||||
// Notify the shell first: the jump switches the sidebar to the child
|
||||
// session's own layout, and the shell re-opens the Subagent page on top
|
||||
// of it (the topology stays rooted at the main agent with the child
|
||||
// highlighted) — the README "page stays open" contract.
|
||||
onOpenChild?.(address)
|
||||
try {
|
||||
sessions.openSubagent?.(address)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] openSubagent failed:', error)
|
||||
}
|
||||
}, [sessions, onOpenChild])
|
||||
|
||||
/** Jump back to the main agent (the topology root) from its node. */
|
||||
const openMain = useCallback((): void => {
|
||||
if (rootId === undefined) return
|
||||
try {
|
||||
sessions.open?.(rootId)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] open session failed:', error)
|
||||
}
|
||||
}, [sessions, rootId])
|
||||
|
||||
const refresh = useCallback((parentSessionId: string): void => {
|
||||
void sessions.refreshSubagents?.(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
const totals = useMemo(
|
||||
() => rootId === undefined
|
||||
? { count: 0, runningCount: 0 }
|
||||
: countSubagentDescendants(byId, rootId),
|
||||
[byId, rootId],
|
||||
)
|
||||
// Session summaries can announce membership before the descriptor-backed
|
||||
// catalog catches up (or a catalog that just went ready is still empty).
|
||||
const summaryBackedLoading = rootId !== undefined
|
||||
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
||||
&& directChildren(byId, rootId).length > 0
|
||||
const readyEmpty = rootCatalog?.state === 'ready'
|
||||
&& rootCatalog.entries.length === 0
|
||||
&& directChildren(byId, rootId ?? '').length === 0
|
||||
const countLabel = totals.count === 0
|
||||
? undefined
|
||||
: totals.runningCount > 0
|
||||
? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
|
||||
: t('subagentCount', { count: totals.count })
|
||||
|
||||
/** Arrow-key tree navigation over the visible rows (official catalog recipe). */
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const focusAt = useCallback((index: number): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
if (items.length === 0) return
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}, [])
|
||||
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
const index = Array.prototype.indexOf.call(items, document.activeElement)
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAt(index + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAt(index < 0 ? items.length - 1 : index - 1)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
focusAt(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
focusAt(items.length - 1)
|
||||
}
|
||||
}, [focusAt])
|
||||
|
||||
return (
|
||||
<div className={css.subagent}>
|
||||
<div className={css.subagentHeader}>
|
||||
<span className={css.subagentTitle}>
|
||||
{t('subagent')}
|
||||
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
|
||||
? ` · ${rootSummary.displayTitle}`
|
||||
: ''}
|
||||
</span>
|
||||
{countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentRefresh}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
disabled={rootId === undefined}
|
||||
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
|
||||
* overflow scrolls horizontally, a close button per tab, a four-way split
|
||||
* button cluster, and the + menu that opens new tabs (explorer / git /
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One + menu option. */
|
||||
export interface NewTabOption {
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
/** Leading icon (Menu row). */
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
|
||||
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
|
||||
|
||||
export interface TabDragPayload {
|
||||
tabId: string
|
||||
paneId: string
|
||||
}
|
||||
|
||||
export function serializeDrag(payload: TabDragPayload): string {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
export function parseDrag(raw: string): TabDragPayload | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as TabDragPayload
|
||||
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
||||
function setTabDragging(active: boolean): void {
|
||||
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-tab-dragging')
|
||||
}
|
||||
|
||||
export function TabBar(props: {
|
||||
paneId: string
|
||||
tabs: SidebarTab[]
|
||||
active: string | null
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onNewTab: (optionId: string) => void
|
||||
newTabOptions: NewTabOption[]
|
||||
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
||||
onDragOver={(event) => {
|
||||
// The strip owns drops on itself (merge into this pane); stopping
|
||||
// propagation keeps the pane root from also running its edge-zone
|
||||
// handler on the same drop.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => { setDragOver(false) }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragOver(false)
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
||||
title={tab.title}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setTabDragging(true)
|
||||
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, tab.id)
|
||||
}}
|
||||
onClick={() => { onActivate(tab.id) }}
|
||||
onAuxClick={(event) => {
|
||||
// Middle-click closes the tab (and suppresses autoscroll).
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
onClose(tab.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={t('close')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
The + sits immediately after the rightmost tab (sticky at the
|
||||
right edge of the scrollport when the tabs overflow, so it stays
|
||||
reachable no matter how many tabs are open).
|
||||
*/}
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={newTabOptions.map(option => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(option.disabled === true ? { disabled: true } : {}),
|
||||
...(option.icon !== undefined ? { icon: option.icon } : {}),
|
||||
}))}
|
||||
onSelect={(id) => {
|
||||
onNewTab(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabBarPlus}
|
||||
aria-label={t('newTab')}
|
||||
title={t('newTab')}
|
||||
onClick={() => { setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* The interactive terminal: xterm.js over a WebSocket to the host pty.
|
||||
* The host replays the session's transcript on connect, then streams live
|
||||
* output; input frames are raw text, resize frames are JSON with
|
||||
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
|
||||
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
|
||||
* a failed pty spawn) stops the loop and shows the reason with a manual
|
||||
* retry, and repeated unreasoned failures surface the close code after three
|
||||
* attempts, so the banner never spins forever.
|
||||
*
|
||||
* Two attach modes share one upgrade endpoint:
|
||||
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
||||
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
||||
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
||||
* terminal closes when the user closes the tab); a bare socket drop
|
||||
* leaves the pty alive (the agent owns the lifetime).
|
||||
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
||||
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
||||
* frame schedules a 0-ms close; a bare socket drop gets the host's
|
||||
* reconnect grace.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
||||
* designed palettes the app's code surfaces use (one-dark family for dark,
|
||||
* one-light family for light), read live so a scheme flip re-themes in
|
||||
* place.
|
||||
*/
|
||||
const ANSI_DARK: Record<string, string> = {
|
||||
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
||||
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
||||
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
||||
}
|
||||
|
||||
const ANSI_LIGHT: Record<string, string> = {
|
||||
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
||||
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
||||
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
||||
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
||||
}
|
||||
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
function xtermTheme(): ITheme {
|
||||
const dark = isDarkScheme()
|
||||
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
|
||||
const { scope, tabId, store } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
theme: xtermTheme(),
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
term.refresh(0, term.rows - 1)
|
||||
}
|
||||
const schemeSub = subscribeColorScheme(applyTheme)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let closed = false
|
||||
let retry: number | undefined
|
||||
let failures = 0
|
||||
|
||||
const wsUrl = (): string => {
|
||||
const url = new URL('/sidebar/ws/terminal', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
// Agent terminals attach by uuid (the host looks them up in the agent
|
||||
// pty registry); UI-tab terminals attach by sessionId+tab (the host
|
||||
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
|
||||
if (isAgentTabId(tabId)) {
|
||||
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
|
||||
} else {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
url.search = params.toString()
|
||||
}
|
||||
// Same construction the app's own downlink WebSockets use (new URL
|
||||
// over location.origin + protocol swap): whatever the environment
|
||||
// does to the app's websockets applies identically here.
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const sendResize = (): void => {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
}
|
||||
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = wsUrl()
|
||||
setLastUrl(url)
|
||||
socket = new WebSocket(url)
|
||||
socket.onopen = () => {
|
||||
failures = 0
|
||||
setConnected(true)
|
||||
setFatal(null)
|
||||
sendResize()
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') term.write(event.data)
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
setConnected(false)
|
||||
// A server-side refusal carries a close code + reason; retrying it
|
||||
// forever would only spin the banner, so surface it with a retry.
|
||||
if (event.code === 1011 && event.reason !== '') {
|
||||
setFatal(event.reason)
|
||||
return
|
||||
}
|
||||
// Unreasoned drops (upgrade rejected, host down, mid-handshake
|
||||
// refusal) normally recover on the next attempt; after a few
|
||||
// consecutive failures stop spinning and show the close code.
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
|
||||
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
|
||||
setFatal(`${t('terminalConnectFailed')}${detail}`)
|
||||
return
|
||||
}
|
||||
if (!closed) retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
socket?.close()
|
||||
}
|
||||
}
|
||||
connectRef.current = connect
|
||||
|
||||
const inputSub = term.onData((data) => {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
|
||||
})
|
||||
const observer = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch {
|
||||
// The terminal may be mid-dispose; ignore.
|
||||
}
|
||||
})
|
||||
observer.observe(host)
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
// pty release). A bare unmount — conversation switch, re-render,
|
||||
// page unload — leaves the tab open, so the socket drop alone hands
|
||||
// the process to the host's reconnect grace: switching back or
|
||||
// refreshing reattaches the SAME shell instead of respawning one.
|
||||
// (The host respawns on its own when the authoritative cwd changed.)
|
||||
// Agent terminals follow the same rule: a close frame kills the pty
|
||||
// (the user closed the sidebar tab); a bare socket drop leaves it
|
||||
// alive (the agent owns the lifetime).
|
||||
if (!store.tabOpen(scope.sessionId, tabId)
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'close' }))
|
||||
}
|
||||
socket?.close()
|
||||
term.dispose()
|
||||
connectRef.current = null
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, tabId, store])
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{fatal !== null && (
|
||||
<div className={css.terminalBanner}>
|
||||
{t('terminalError')}: {fatal}
|
||||
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { setFatal(null); connectRef.current?.() }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
<div ref={hostRef} className={css.terminal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
|
||||
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
|
||||
* save, and a preview/edit toggle for markdown files. Registered as the
|
||||
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
|
||||
* fetches the content through the fsRead strategy and passes it in props,
|
||||
* so this component never fetches or dispatches — it only edits.
|
||||
*
|
||||
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
||||
* row below the host's title bar, VSCode-style.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<CodeMirrorView | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
||||
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
// A new file (tab switch) starts clean: fresh preview mode, no draft.
|
||||
useEffect(() => {
|
||||
setMode('preview')
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty/draft state through the update
|
||||
// listener. For markdown the view stays mounted while previewing (hidden),
|
||||
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (content === undefined) return
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const language = languageForPath(path)
|
||||
const themeComp = new CmThemeCompartment()
|
||||
themeCompRef.current = themeComp
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
CodeMirrorView.lineWrapping,
|
||||
lineNumbers(),
|
||||
history(),
|
||||
EditorState.tabSize.of(2),
|
||||
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
|
||||
cmSurfaceTheme,
|
||||
themeComp.of(dark),
|
||||
...(language !== null ? [language] : []),
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
setDraft(update.state.doc.toString())
|
||||
setDirty(true)
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => { save(); return true },
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
],
|
||||
})
|
||||
const view = new CodeMirrorView({ state, parent: host })
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
themeCompRef.current = null
|
||||
}
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
}, [content, path])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
// scheme-dependent extensions; everything else is untouched).
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
const themeComp = themeCompRef.current
|
||||
if (view === null || themeComp === null) return
|
||||
view.dispatch({ effects: themeComp.reconfigure(dark) })
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal).
|
||||
useEffect(() => {
|
||||
if (mode === 'edit') viewRef.current?.requestMeasure()
|
||||
}, [mode])
|
||||
|
||||
const save = (): void => {
|
||||
const view = viewRef.current
|
||||
if (view === null || savingRef.current) return
|
||||
savingRef.current = true
|
||||
setSaveState('saving')
|
||||
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
|
||||
savingRef.current = false
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('saved')
|
||||
}).catch(() => {
|
||||
savingRef.current = false
|
||||
setSaveState('failed')
|
||||
})
|
||||
}
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
{markdown && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={save}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
{editable && (
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
|
||||
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
|
||||
* cwd from the client's own list summary. The host prefers its attached
|
||||
* session header and uses the summary cwd only while the session is still
|
||||
* hydrating at page load (a detached session would otherwise fail the
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
|
||||
/** One wire failure. */
|
||||
export class SidebarApiError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Explorer row (host fs-tree shape). */
|
||||
export interface FsEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** Git status entry (host git shape). */
|
||||
export interface GitStatusEntry {
|
||||
path: string
|
||||
xy: string
|
||||
}
|
||||
|
||||
/** Git status snapshot. */
|
||||
export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
}
|
||||
|
||||
/** One git log row. */
|
||||
export interface GitLogEntry {
|
||||
/** Short hash (7+ chars, display). */
|
||||
hash: string
|
||||
/** Full 40-char hash (advanced operations). */
|
||||
hashFull: string
|
||||
subject: string
|
||||
author: string
|
||||
/** ISO 8601 author date (`%ai`). */
|
||||
date: string
|
||||
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
||||
refs: string
|
||||
}
|
||||
|
||||
/** Text read result. */
|
||||
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
|
||||
/** Binary read result (no content; images load through the media route).
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/sidebar/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
||||
= await response.json().catch(() => null)
|
||||
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
||||
throw new SidebarApiError(
|
||||
parsed?.error?.code ?? 'http',
|
||||
parsed?.error?.message ?? `HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/** One request's session scope: the conversation id plus its cwd when known. */
|
||||
export interface SessionScope {
|
||||
sessionId: string
|
||||
/** The session's working directory from the client list summary (optional). */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** Fold a scope into a JSON payload ({cwd} only when present). */
|
||||
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
||||
}
|
||||
|
||||
/** The sidebar API surface (session scope threaded through every call). */
|
||||
export const api = {
|
||||
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
|
||||
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
|
||||
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string) =>
|
||||
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
||||
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string) =>
|
||||
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
||||
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(skip !== undefined ? { skip } : {}),
|
||||
}), signal),
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
||||
/** Discard the worktree changes of one file (the index is untouched). */
|
||||
gitDiscard: (scope: SessionScope, path: string) =>
|
||||
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
||||
/** Revert one commit onto the current branch. */
|
||||
gitRevert: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
gitCherryPick: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
||||
/** Release a terminal's process immediately (tab closed; the WS close frame
|
||||
* may be unreachable while the socket is down, so the host also accepts
|
||||
* this explicit route). */
|
||||
ptyClose: (scope: SessionScope, tab: string) =>
|
||||
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
/** Merge a patch into the side card preferences (revision-guarded). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
export function mediaUrl(scope: SessionScope, path: string): string {
|
||||
return fileUrl(scope, path, false)
|
||||
}
|
||||
|
||||
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
|
||||
* `Content-Disposition: attachment`, so the browser saves the file. */
|
||||
export function downloadUrl(scope: SessionScope, path: string): string {
|
||||
return fileUrl(scope, path, true)
|
||||
}
|
||||
|
||||
/** Shared URL builder for the /sidebar/file route (media vs download). */
|
||||
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
if (download) params.set('download', '1')
|
||||
return `/sidebar/file?${params.toString()}`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The "no preview — download instead" pane: shown by the editor host when no
|
||||
* viewer can render a file (binary without a registered renderer, or the
|
||||
* `binary-download` strategy) and registered as the `binary-download` viewer
|
||||
* component so the declarative route and the host fallback share one UI.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { downloadUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
|
||||
const { scope, path } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Built-in registration: the plugin registers its own tab pages and file
|
||||
* previewers through the same {@link BetterSidebarService} external plugins
|
||||
* use — eating its own dogfood. The descriptors live next to their feature
|
||||
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
|
||||
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
|
||||
* HMR-safe).
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import { builtinTabs } from './tabs.tsx'
|
||||
import { builtinViewers } from './viewers.tsx'
|
||||
|
||||
/**
|
||||
* Register all built-in tabs and viewers with the service. Returns a
|
||||
* disposer that unregisters everything (cordis auto-invokes it on fiber
|
||||
* disposal). The `ctx` is threaded into tab descriptors that need it
|
||||
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
|
||||
*/
|
||||
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of builtinTabs(ctx)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
for (const viewer of builtinViewers()) {
|
||||
disposers.push(service.registerFileViewer(viewer))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / subagent / editor / diff) through the same
|
||||
* {@link BetterSidebarService} external plugins use — eating its own
|
||||
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
|
||||
* mints `terminal:<n>` ids through `createTab`.
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { TerminalView } from '../TerminalView.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 6 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switch renders under this row in
|
||||
// the Side card settings page (the Subagent page's own related setting).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch renders
|
||||
// under this row in the Side card settings page (the Terminal page's
|
||||
// own related setting; the host gates the toolset on it independently).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
title: () => t('settingsToolsTitle'),
|
||||
desc: () => t('settingsToolsDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
},
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
component: ({ scope, tab, store }) => (
|
||||
<TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: ({ scope, tab }) => (
|
||||
tab.diff === undefined ? null
|
||||
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The 8 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
|
||||
* binary-download), exactly like external plugins register theirs. The
|
||||
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DocxView, XlsxView } from '../office-view.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { PptxView } from '../PptxView.tsx'
|
||||
import { TextEditor } from '../TextEditor.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 8 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
title: () => t('viewerImage'),
|
||||
icon: (size: number) => <IconImageOutline16 size={size} />,
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={url} alt={title} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: () => t('viewerPdf'),
|
||||
icon: (size: number) => <IconPdfOutline16 size={size} />,
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
|
||||
// before the catch-all code viewer on the head re-match.
|
||||
detect: (_path, head) => head.includes(0),
|
||||
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
||||
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
||||
* the panel in both schemes; only the syntax token colors need concrete
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* TextEditor), so the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
/** Token-driven surface shared by both schemes (pure CSS values). */
|
||||
export const cmSurfaceTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--ds-font-family-code)',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-tertiary)',
|
||||
border: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
/** Scheme-specific surface tints (selection, active line). */
|
||||
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
|
||||
return EditorView.theme({
|
||||
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
},
|
||||
'.cm-activeLine, .cm-activeLineGutter': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
|
||||
interface HighlightRule {
|
||||
tag: Tag | readonly Tag[]
|
||||
color?: string
|
||||
fontStyle?: string
|
||||
}
|
||||
|
||||
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
||||
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: tags.string, color: '#98c379' },
|
||||
{ tag: tags.number, color: '#d19a66' },
|
||||
{ tag: tags.bool, color: '#d19a66' },
|
||||
{ tag: tags.atom, color: '#d19a66' },
|
||||
{ tag: tags.typeName, color: '#e5c07b' },
|
||||
{ tag: tags.className, color: '#e5c07b' },
|
||||
{ tag: tags.propertyName, color: '#e06c75' },
|
||||
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
||||
{ tag: tags.variableName, color: '#e06c75' },
|
||||
{ tag: tags.operator, color: '#56b6c2' },
|
||||
{ tag: tags.tagName, color: '#e06c75' },
|
||||
{ tag: tags.attributeName, color: '#d19a66' },
|
||||
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#e5c07b' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** one-light syntax palette (the light counterpart of one-dark). */
|
||||
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#a626a4' },
|
||||
{ tag: tags.string, color: '#50a14f' },
|
||||
{ tag: tags.number, color: '#986801' },
|
||||
{ tag: tags.bool, color: '#0184bc' },
|
||||
{ tag: tags.atom, color: '#0184bc' },
|
||||
{ tag: tags.typeName, color: '#c18401' },
|
||||
{ tag: tags.className, color: '#c18401' },
|
||||
{ tag: tags.propertyName, color: '#e45649' },
|
||||
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
||||
{ tag: tags.variableName, color: '#e45649' },
|
||||
{ tag: tags.operator, color: '#383a42' },
|
||||
{ tag: tags.tagName, color: '#e45649' },
|
||||
{ tag: tags.attributeName, color: '#986801' },
|
||||
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#c18401' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
||||
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
|
||||
return [
|
||||
cmSurfaceTint(dark),
|
||||
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Compartment holding the two scheme-dependent extensions. Created once
|
||||
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
|
||||
* the document, undo history, scroll and keymaps survive re-theming.
|
||||
*/
|
||||
export class CmThemeCompartment {
|
||||
private readonly compartment = new Compartment()
|
||||
|
||||
/** `of(...)` payload for EditorState.create. */
|
||||
of(dark: boolean): ReturnType<Compartment['of']> {
|
||||
return this.compartment.of(cmThemeExtensions(dark))
|
||||
}
|
||||
|
||||
/** Reconfigure for a new scheme. */
|
||||
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
|
||||
return this.compartment.reconfigure(cmThemeExtensions(dark))
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pure editor-load planning: the decision logic the editor host needs to
|
||||
* turn a matched file viewer + a host fs.read result into a render action.
|
||||
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
|
||||
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
|
||||
*
|
||||
* The host flow this module drives:
|
||||
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
|
||||
* 2. `planFirstMatch` dispatches its fetchStrategy.
|
||||
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
|
||||
* decides what to do with the result — including the head-based
|
||||
* re-match that lets a `detect` viewer claim a binary the extension
|
||||
* match could not see (the builtin NUL probe on `binary-download`).
|
||||
*/
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
|
||||
/** One host fs.read result (mirror of the wire; `head` present when binary). */
|
||||
export interface FsReadOutcome {
|
||||
binary: boolean
|
||||
content: string
|
||||
truncated: boolean
|
||||
/** base64 of the first bytes (present on binary reads; sniffing material). */
|
||||
head?: string
|
||||
}
|
||||
|
||||
/** What the editor host should do next. */
|
||||
export type EditorLoadAction =
|
||||
/** No renderer: show the download UI. */
|
||||
| { kind: 'binary' }
|
||||
/** Render `viewer`'s component with the carried payload. */
|
||||
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
/** Fetch the file through the host (fsRead strategy). */
|
||||
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
|
||||
/** Call the viewer's load() and render with its return value. */
|
||||
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
|
||||
|
||||
/** Decode the host's base64 head bytes into the sniffing buffer. */
|
||||
export function decodeHead(headBase64: string): Uint8Array {
|
||||
const binary = atob(headBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
|
||||
* `binary-download` strategy both mean "no client-side renderer" → the
|
||||
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
|
||||
* strategies (pure, but scope-bound — injected by the host).
|
||||
*/
|
||||
export function planFirstMatch(
|
||||
viewer: FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl':
|
||||
case 'none':
|
||||
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
|
||||
case 'custom':
|
||||
return { kind: 'customLoad', viewer }
|
||||
case 'fsRead':
|
||||
return { kind: 'fetchFsRead', viewer }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what an fsRead result means for the editor.
|
||||
* - Text: the first match stands (content is valid for any fsRead viewer).
|
||||
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
|
||||
* a plugin sniffing a binary format) may claim the file. `custom` viewers
|
||||
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
|
||||
* an fsRead viewer or nothing cannot render binary → download UI.
|
||||
*/
|
||||
export function planFsReadOutcome(
|
||||
viewer: FileViewerDescriptor,
|
||||
result: FsReadOutcome,
|
||||
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (!result.binary) {
|
||||
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
|
||||
}
|
||||
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
|
||||
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
|
||||
return { kind: 'customLoad', viewer: claimed }
|
||||
}
|
||||
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
|
||||
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
|
||||
}
|
||||
return { kind: 'binary' }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none) and a diff glyph. Per-tab icons live on the tab
|
||||
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
|
||||
* mapping was registry-ized with the tab types.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
* a rounded frame with a prompt chevron and underscore cursor.
|
||||
*/
|
||||
export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */
|
||||
export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 5h3M5.5 3.5v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
export const IconImageOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PDF viewer glyph: a document frame with the "PDF" label. */
|
||||
export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Image extension dispatch, separate from React for unit testing. */
|
||||
export const IMAGE_EXTENSIONS = [
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif',
|
||||
] as readonly string[]
|
||||
|
||||
export function isImageExt(ext: string): boolean {
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
+147
-454
@@ -1,471 +1,164 @@
|
||||
/**
|
||||
* apes-sidebar client-half entry (Phase 1: the five real `apes:*` tabs).
|
||||
*
|
||||
* Loaded through the package.json `dsh.client.inject` declaration (web
|
||||
* platform). The entry registers the five apes-sidebar tab descriptors —
|
||||
* `apes:tasks`, `apes:agents`, `apes:apps`, `apes:messages`, `apes:profile` —
|
||||
* into the better-sidebar host's `ctx.betterSidebar` registry, then disposes
|
||||
* cleanly on fiber teardown (HMR-safe).
|
||||
*
|
||||
* Registration is wrapped in a single `ctx.effect(...)`: the effect body
|
||||
* registers the i18n dictionaries plus all five tabs and returns the combined
|
||||
* disposer, which cordis invokes on activation teardown. A missing
|
||||
* `betterSidebar` (bare DSH, no better-sidebar installed) degrades silently —
|
||||
* a missing peer must never break the shell.
|
||||
*
|
||||
* Type base: the descriptors and the component props are the REAL
|
||||
* better-sidebar `TabDescriptor` / `TabComponentProps` (type-only import —
|
||||
* erased at build, never bundled, and it triggers the `declare module
|
||||
* '@deepseek-ai/cordis'` augmentation so `ctx.betterSidebar` type-checks).
|
||||
*
|
||||
* inject list (client-only):
|
||||
* - betterSidebar: the better-sidebar registry service (peer, optional)
|
||||
* - locale: the DSH locale runtime (dictionaries register under apesSidebar)
|
||||
* - sessions: the client session list feed (drives tasks + agents)
|
||||
* Client half of dsh-better-sidebar: resolves the user's "Side card"
|
||||
* preferences through the plugin's own fenced settings route, mounts the
|
||||
* right sidebar portal (inside an error boundary so a rendering failure
|
||||
* shows an error strip instead of a blank panel), registers the turn-tail
|
||||
* interception, and contributes the Side card settings section to the DSH
|
||||
* Settings shell. Requires the runtime's slots and sessions services; the
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
*/
|
||||
import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
|
||||
import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar'
|
||||
import { t, registerLocales, useApesLocale } from './locales.ts'
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { createBetterSidebarService } from './service.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions', 'connection']
|
||||
|
||||
/**
|
||||
* The five tab ids, in + menu order (ascending). Phase 2 adds badge counts
|
||||
* and auto-collapse on top of these same descriptors.
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
* whole panel silently — it shows a dismissible error strip and logs the
|
||||
* stack for diagnosis.
|
||||
*/
|
||||
const TABS = {
|
||||
tasks: 'apes:tasks',
|
||||
agents: 'apes:agents',
|
||||
apps: 'apes:apps',
|
||||
messages: 'apes:messages',
|
||||
profile: 'apes:profile',
|
||||
} as const
|
||||
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
/**
|
||||
* The cordis Context face this entry resolves against: the better-sidebar
|
||||
* `Context` (a `CordisContext & SidebarContextShape` intersection that
|
||||
* carries `sessions`, `locale`, and `betterSidebar`). `betterSidebar` is
|
||||
* typed required by the peer, but a bare DSH profile lacks it at runtime —
|
||||
* the apply() guard handles the absence, so we keep the peer's type as-is
|
||||
* and guard on the value.
|
||||
*/
|
||||
type ApesClientContext = Context
|
||||
|
||||
/**
|
||||
* Read the client session list uSES-safely, with `visible`-gated pausing
|
||||
* (Phase 2 auto-collapse). `useSyncExternalStore` re-subscribes whenever the
|
||||
* `subscribe` identity changes, so passing a no-op subscribe while the tab is
|
||||
* not the active-open one stops live session-list churn from re-rendering a
|
||||
* hidden panel — exactly better-sidebar's "live views pause otherwise"
|
||||
* contract. The snapshot itself is always read (cheap), so a hidden tab still
|
||||
* shows its last-known data when re-shown.
|
||||
*/
|
||||
const NOOP_SUBSCRIBE = () => () => {}
|
||||
function useSessionList(ctx: Context, visible: boolean) {
|
||||
const list = ctx.sessions.list
|
||||
return useSyncExternalStore(
|
||||
visible ? list.subscribe : NOOP_SUBSCRIBE,
|
||||
list.getSnapshot,
|
||||
list.getSnapshot,
|
||||
)
|
||||
}
|
||||
|
||||
/** Count of sessions whose agent is currently running (badge source). */
|
||||
function runningCount(ctx: Context): number {
|
||||
const { byId } = ctx.sessions.list.getSnapshot()
|
||||
let n = 0
|
||||
for (const id in byId) {
|
||||
if (byId[id]?.running) n++
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Count of subagent-origin sessions (badge source for apes:agents). */
|
||||
function subagentCount(ctx: Context): number {
|
||||
const { byId } = ctx.sessions.list.getSnapshot()
|
||||
let n = 0
|
||||
for (const id in byId) {
|
||||
if (byId[id]?.origin === 'subagent') n++
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab state (Phase 2): the apes:tasks panel's `showInactive` preference,
|
||||
* persisted across reloads through better-sidebar's settings service
|
||||
* (`ctx.settings.register`). The namespace handle is registered ONCE at
|
||||
* module scope (registering per render would re-stack schemas); the hook
|
||||
* reads it uSES-safely and writes back via `update`.
|
||||
*
|
||||
* Degrades to the default (`showInactive: true`) when `ctx.settings` is
|
||||
* absent — a bare DSH profile or a unit-test fake that omits the service.
|
||||
*/
|
||||
const TASKS_NS = 'apesSidebar.tasks'
|
||||
interface TasksPrefs {
|
||||
showInactive: boolean
|
||||
}
|
||||
let tasksPrefsHandle: {
|
||||
get(): TasksPrefs
|
||||
watch(cb: (next: TasksPrefs, prev: TasksPrefs) => void): () => void
|
||||
update(patch: Partial<TasksPrefs>): Promise<void>
|
||||
} | undefined
|
||||
|
||||
function getTasksPrefsHandle(ctx: Context) {
|
||||
if (tasksPrefsHandle) return tasksPrefsHandle
|
||||
if (!ctx.settings?.register) return undefined
|
||||
tasksPrefsHandle = ctx.settings.register<TasksPrefs>(TASKS_NS, null, {
|
||||
base: { showInactive: true },
|
||||
applies: 'live',
|
||||
})
|
||||
return tasksPrefsHandle
|
||||
}
|
||||
|
||||
const DEFAULT_TASKS_PREFS: TasksPrefs = { showInactive: true }
|
||||
|
||||
/**
|
||||
* Pure row filter for the tasks list: when `showInactive` is false, only
|
||||
* running sessions render. Exported for unit testing (the hook itself needs
|
||||
* a render, but this decision is a plain function).
|
||||
*/
|
||||
export function filterTasksRows<T extends { running?: boolean }>(
|
||||
all: T[],
|
||||
showInactive: boolean,
|
||||
): T[] {
|
||||
return showInactive ? all : all.filter((s) => s.running)
|
||||
}
|
||||
|
||||
function useTasksPrefs(ctx: Context): { prefs: TasksPrefs; setShowInactive: (v: boolean) => void } {
|
||||
const handle = getTasksPrefsHandle(ctx)
|
||||
const prefs = useSyncExternalStore(
|
||||
handle ? (cb) => handle.watch(cb) : NOOP_SUBSCRIBE,
|
||||
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
|
||||
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
|
||||
)
|
||||
return {
|
||||
prefs,
|
||||
setShowInactive: (v: boolean) => {
|
||||
void handle?.update({ showInactive: v })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A row in a list view: id, title, and optional flag chips. */
|
||||
function SessionRow({
|
||||
title,
|
||||
running,
|
||||
current,
|
||||
subagent,
|
||||
}: {
|
||||
title: string
|
||||
running?: boolean
|
||||
current?: boolean
|
||||
subagent?: boolean
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-apes-row
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 6px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{title}
|
||||
</span>
|
||||
{running && <Chip label={t('tasks.running')} tone="run" />}
|
||||
{current && <Chip label={t('tasks.current')} tone="cur" />}
|
||||
{subagent && <Chip label={t('tasks.subagent')} tone="sub" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small status pill. */
|
||||
function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): ReactElement {
|
||||
const bg = tone === 'run' ? '#2ea04333' : tone === 'cur' ? '#1f6feb33' : '#6e40c933'
|
||||
const fg = tone === 'run' ? '#2ea043' : tone === 'cur' ? '#1f6feb' : '#6e40c9'
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: '16px',
|
||||
padding: '0 5px',
|
||||
borderRadius: 8,
|
||||
background: bg,
|
||||
color: fg,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A panel shell: a title bar + a scrollable body. */
|
||||
function Panel({ titleKey, children }: { titleKey: string; children: ReactNode }): ReactElement {
|
||||
return (
|
||||
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The empty-state line every panel falls back to. */
|
||||
function Empty({ key: key }: { key: string }): ReactElement {
|
||||
return <div style={{ opacity: 0.55, fontSize: 12 }}>{t(key)}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:tasks — the session list. Running/current/subagent rows are flagged.
|
||||
* Clicking a row selects it as current (when the host exposes `open`).
|
||||
*/
|
||||
function TasksView(props: TabComponentProps): ReactElement {
|
||||
useApesLocale()
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const { prefs, setShowInactive } = useTasksPrefs(props.ctx)
|
||||
// Per-tab state: when showInactive is off, only running sessions render.
|
||||
const all = Object.values(list.byId)
|
||||
const rows = prefs.showInactive ? all : all.filter((s) => s.running)
|
||||
const open = (id: string) => props.ctx.sessions.open?.(id)
|
||||
return (
|
||||
<Panel titleKey="tab.tasks.title">
|
||||
{/* The showInactive toggle — the persisted per-tab state. */}
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
opacity: 0.8,
|
||||
marginBottom: 6,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
{t('tasks.showInactive')}
|
||||
</label>
|
||||
{rows.length === 0 ? (
|
||||
<Empty key="tasks.empty" />
|
||||
) : (
|
||||
rows.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
title={s.displayTitle}
|
||||
running={s.running}
|
||||
current={s.id === list.current}
|
||||
subagent={s.origin === 'subagent'}
|
||||
// The row is a button only when a host `open` is available.
|
||||
{...{ onClick: () => open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:agents — the subagent children, grouped by their durable parent
|
||||
* session. Reads the same sessions feed and filters `origin === 'subagent'`.
|
||||
*/
|
||||
function AgentsView(props: TabComponentProps): ReactElement {
|
||||
useApesLocale()
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const children = Object.values(list.byId).filter((s) => s.origin === 'subagent')
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<Panel titleKey="tab.agents.title">
|
||||
<Empty key="agents.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('agents.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
// Group by parentId (undefined groups under a synthetic root).
|
||||
const groups = new Map<string, typeof children>()
|
||||
for (const s of children) {
|
||||
const key = s.parentId ?? ''
|
||||
const arr = groups.get(key) ?? []
|
||||
arr.push(s)
|
||||
groups.set(key, arr)
|
||||
}
|
||||
const jump = (childSessionId: string) => props.onSubagentJump?.(childSessionId)
|
||||
return (
|
||||
<Panel titleKey="tab.agents.title">
|
||||
{[...groups.entries()].map(([parentId, rows]) => (
|
||||
<div key={parentId || 'root'} style={{ marginBottom: 10 }}>
|
||||
{parentId && (
|
||||
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>
|
||||
{list.byId[parentId]?.displayTitle ?? parentId}
|
||||
</div>
|
||||
)}
|
||||
{rows.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
title={s.displayTitle}
|
||||
running={s.running}
|
||||
subagent
|
||||
{...{ onClick: () => jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
|
||||
/>
|
||||
))}
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={css.boundaryError}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */
|
||||
function AppsView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.apps.title">
|
||||
<Empty key="apps.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('apps.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */
|
||||
function MessagesView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.messages.title">
|
||||
<Empty key="messages.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('messages.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:profile — account/runtime overview. Phase 1 shows the shell + a hint;
|
||||
* Phase 2 reads model/provider from a host route (none exposed yet).
|
||||
*/
|
||||
function ProfileView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.profile.title">
|
||||
<div style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<span style={{ opacity: 0.55 }}>{t('profile.model')}: </span>
|
||||
<span>{t('profile.unknown')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ opacity: 0.55 }}>{t('profile.provider')}: </span>
|
||||
<span>{t('profile.unknown')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ opacity: 0.45, fontSize: 12 }}>{t('profile.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the five tab descriptors. Each `component` receives the real
|
||||
* better-sidebar `TabComponentProps` (ctx / store / scope / tab / visible).
|
||||
*
|
||||
* Phase 2 additions:
|
||||
* - `badge` on tasks/agents: a cheap count pill (running sessions / subagent
|
||||
* count). Called on every tab-bar render, so it reads the live sessions
|
||||
* snapshot synchronously and returns `null` when the count is 0 (hidden).
|
||||
* - `visible` is now honored by the live views: `useSessionList` swaps to a
|
||||
* no-op subscribe when the tab is not the active-open one (auto-collapse —
|
||||
* hidden panels stop re-rendering on session-list churn).
|
||||
*/
|
||||
function buildDescriptors(): TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: TABS.tasks,
|
||||
title: () => t('tab.tasks.title'),
|
||||
order: 90,
|
||||
single: true,
|
||||
// Running-session count pill (99+ capped by the host; 0 → hidden).
|
||||
badge: (ctx) => {
|
||||
const n = runningCount(ctx)
|
||||
return n === 0 ? null : n
|
||||
},
|
||||
component: (props) => <TasksView {...props} />,
|
||||
},
|
||||
{
|
||||
id: TABS.agents,
|
||||
title: () => t('tab.agents.title'),
|
||||
order: 91,
|
||||
single: true,
|
||||
// Active-subagent count pill (0 → hidden).
|
||||
badge: (ctx) => {
|
||||
const n = subagentCount(ctx)
|
||||
return n === 0 ? null : n
|
||||
},
|
||||
component: (props) => <AgentsView {...props} />,
|
||||
},
|
||||
{
|
||||
id: TABS.apps,
|
||||
title: () => t('tab.apps.title'),
|
||||
order: 92,
|
||||
single: true,
|
||||
component: () => <AppsView />,
|
||||
},
|
||||
{
|
||||
id: TABS.messages,
|
||||
title: () => t('tab.messages.title'),
|
||||
order: 93,
|
||||
single: true,
|
||||
component: () => <MessagesView />,
|
||||
},
|
||||
{
|
||||
id: TABS.profile,
|
||||
title: () => t('tab.profile.title'),
|
||||
order: 94,
|
||||
single: true,
|
||||
component: () => <ProfileView />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the i18n dictionaries plus all five tabs; returns the combined
|
||||
* disposer (locale registrations + the five tab disposers). Safe to call
|
||||
* from inside a cordis effect.
|
||||
*/
|
||||
function registerAll(ctx: ApesClientContext): () => void {
|
||||
const better = ctx.betterSidebar
|
||||
const disposers: (() => void)[] = [registerLocales(ctx.locale)]
|
||||
for (const descriptor of buildDescriptors()) {
|
||||
disposers.push(better.registerTab(descriptor))
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try {
|
||||
d()
|
||||
} catch {
|
||||
// Already disposed (double-dispose on HMR) — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// One store instance per activation: production code creates it only here,
|
||||
// then hands it to the mounted panel and closes over it in the slot
|
||||
// registrations (the official createXXXStore() factory rule — no
|
||||
// module-level singleton).
|
||||
const sidebarStore = createSidebarStore()
|
||||
// The sidebar registry service: external plugins register tab types and
|
||||
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
|
||||
// Published before the panel mounts so consumers injecting 'betterSidebar'
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// Register the plugin's own built-in tabs and viewers through the same
|
||||
// service (eating our own dogfood). The disposer unregisters them on
|
||||
// fiber disposal (HMR-safe).
|
||||
ctx.effect(
|
||||
() => registerBuiltins(ctx, service),
|
||||
'dsh-better-sidebar: register built-in tabs and viewers',
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||
// to the page so a blank panel is never the only symptom.
|
||||
const fail = (phase: string, error: unknown): void => {
|
||||
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
|
||||
try {
|
||||
const bar = document.createElement('div')
|
||||
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
|
||||
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
|
||||
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
|
||||
document.body.appendChild(bar)
|
||||
} catch {
|
||||
// Nothing left to report with.
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
// schema defaults; the sidebar still mounts (a stalled wire gives up
|
||||
// after the timeout and mounts on the defaults).
|
||||
const prefs = await Promise.race([
|
||||
loadPrefs(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
|
||||
/** The cordis client entry. */
|
||||
export function apply(ctx: ApesClientContext): void {
|
||||
if (!ctx.betterSidebar) {
|
||||
// better-sidebar not present in this profile: the tabs cannot register.
|
||||
// Expected on a bare DSH; the host half still mounts (logs only).
|
||||
// Do NOT throw — a missing peer must degrade, never break the shell.
|
||||
return
|
||||
}
|
||||
if (typeof ctx.effect === 'function') {
|
||||
// cordis effect takes a zero-arg thunk; capture ctx in the closure.
|
||||
// The body returns the combined disposer; cordis runs it on activation
|
||||
// teardown (HMR-safe).
|
||||
ctx.effect(() => registerAll(ctx))
|
||||
} else {
|
||||
// No effect API (non-cordis harness / unit test): register synchronously;
|
||||
// disposal rides process teardown.
|
||||
registerAll(ctx)
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerTurnTailInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route, keeps the shared store in sync, and renders the
|
||||
// declarative enable/disable inventory from the tab/viewer registry.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore, service }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Interception of the chat's produced-files row: the turn-tail chain entry
|
||||
* that replaces ui-deliverables' row when the closing turn produced files.
|
||||
* The takeover looks identical (same chip row); the chips open the file in
|
||||
* the sidebar instead of the host OS. Priority -1 runs before the default-0
|
||||
* deliverables entry; when nothing was produced the selector returns null
|
||||
* and the original row renders unchanged.
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
||||
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
|
||||
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
||||
const absolute = resolveSidebarPath(summary?.cwd, path)
|
||||
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||
// Route through the sidebar service so the editor descriptor's dedupeKey
|
||||
// (per-path) applies; the id is path-derived so multiple editors coexist.
|
||||
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
}
|
||||
|
||||
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||
export function SidebarProducedFiles(props: {
|
||||
matched: readonly string[]
|
||||
openInSidebar: (path: string) => void
|
||||
}) {
|
||||
const { matched, openInSidebar } = props
|
||||
const shown = matched.slice(0, 6)
|
||||
const hidden = matched.length - shown.length
|
||||
return (
|
||||
<div className={css.producedRow}>
|
||||
<span className={css.producedLabel}>{t('produced')}</span>
|
||||
{shown.map(path => {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
const name = at === -1 ? path : path.slice(at + 1)
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.producedChip}
|
||||
title={path}
|
||||
onClick={() => { openInSidebar(path) }}
|
||||
>
|
||||
<IconCodeOutline16 size={12} />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the turn-tail interception (returns the disposer). */
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
// deliverables behavior instead of offering chips that cannot open.
|
||||
select: (owner) => {
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Syntax highlighting for the file editor: extension → CodeMirror language
|
||||
* mapping. The key derivation is pure and unit-tested; the factories pull in
|
||||
* the CodeMirror language packages (bundled into the client).
|
||||
*/
|
||||
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { java } from '@codemirror/lang-java'
|
||||
import { cpp } from '@codemirror/lang-cpp'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { go } from '@codemirror/lang-go'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties'
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
export function extOf(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/** Language key for an extension, or null for plain text. Pure (tested). */
|
||||
export function languageKeyForExt(ext: string): string | null {
|
||||
switch (ext) {
|
||||
case 'js': case 'mjs': case 'cjs': return 'js'
|
||||
case 'jsx': return 'jsx'
|
||||
case 'ts': case 'mts': case 'cts': return 'ts'
|
||||
case 'tsx': return 'tsx'
|
||||
case 'json': case 'jsonc': return 'json'
|
||||
case 'md': case 'markdown': return 'md'
|
||||
case 'py': case 'pyw': return 'python'
|
||||
case 'html': case 'htm': return 'html'
|
||||
case 'css': return 'css'
|
||||
case 'xml': case 'xsl': return 'xml'
|
||||
case 'yaml': case 'yml': return 'yaml'
|
||||
case 'sql': return 'sql'
|
||||
case 'java': return 'java'
|
||||
case 'c': case 'h': return 'c'
|
||||
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
|
||||
case 'rs': return 'rust'
|
||||
case 'go': return 'go'
|
||||
case 'php': return 'php'
|
||||
case 'sh': case 'bash': case 'zsh': return 'shell'
|
||||
case 'toml': return 'toml'
|
||||
case 'nginx': case 'conf': return 'nginx'
|
||||
case 'dockerfile': case 'docker': return 'dockerfile'
|
||||
case 'properties': case 'env': return 'properties'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
js: () => javascript({ jsx: true }),
|
||||
jsx: () => javascript({ jsx: true }),
|
||||
ts: () => javascript({ typescript: true }),
|
||||
tsx: () => javascript({ typescript: true, jsx: true }),
|
||||
json: () => json(),
|
||||
md: () => markdown(),
|
||||
python: () => python(),
|
||||
html: () => html(),
|
||||
css: () => css(),
|
||||
xml: () => xml(),
|
||||
yaml: () => yaml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
rust: () => rust(),
|
||||
go: () => go(),
|
||||
php: () => php(),
|
||||
shell: () => StreamLanguage.define(shell),
|
||||
toml: () => StreamLanguage.define(toml),
|
||||
nginx: () => StreamLanguage.define(nginx),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
properties: () => StreamLanguage.define(properties),
|
||||
}
|
||||
|
||||
/** The CodeMirror language support for a path, or null for plain text. */
|
||||
export function languageForPath(path: string): Language | LanguageSupport | null {
|
||||
const key = languageKeyForExt(extOf(path))
|
||||
return key === null ? null : FACTORIES[key]!()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
|
||||
* floating over it — the app shell (#root, the AppFrame three-column grid)
|
||||
* gives up the panel's width on the right. Only the center column is
|
||||
* flexible (1fr), so the squeeze lands exactly on the conversation output
|
||||
* and the input bar, like a VSCode sidebar. The fixed panel covers the
|
||||
* vacated strip, which looks seamless because #root's background is the
|
||||
* theme base.
|
||||
*
|
||||
* The width rides a CSS variable updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margin and the panel slide
|
||||
* on the same theme duration. Width drags disable the transition so the
|
||||
* layout tracks the pointer.
|
||||
*/
|
||||
#root {
|
||||
margin-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+322
-125
@@ -1,136 +1,333 @@
|
||||
/**
|
||||
* apes-sidebar i18n dictionaries, registered into the DSH locale runtime
|
||||
* under the `apesSidebar` namespace (follows the DSH i18n system: the
|
||||
* active locale is the Host-backed preference, not the raw browser language).
|
||||
*
|
||||
* zh + en are primary. A third language falls back to en (the better-sidebar
|
||||
* convention: every new zh key must also have an en entry).
|
||||
*
|
||||
* The module tracks the active locale in a small external store so `t()`
|
||||
* always resolves against the current locale, and React components re-render
|
||||
* on locale change via {@link useApesLocale} (useSyncExternalStore).
|
||||
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
|
||||
* system's locale seat, so it reads the browser language directly (the DSH
|
||||
* locale preference and the browser language agree in practice; both default
|
||||
* from navigator.language anyway).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ApesLocaleService } from '../context-types.ts'
|
||||
|
||||
const NS = 'apesSidebar'
|
||||
|
||||
const zh: Record<string, string> = {
|
||||
'tab.tasks.title': '任务',
|
||||
'tab.agents.title': 'Agent',
|
||||
'tab.messages.title': '消息',
|
||||
'tab.apps.title': '应用',
|
||||
'tab.profile.title': '账户',
|
||||
'tasks.empty': '暂无会话',
|
||||
'tasks.running': '运行中',
|
||||
'tasks.current': '当前',
|
||||
'tasks.subagent': '子代理',
|
||||
'tasks.showInactive': '显示非运行会话',
|
||||
'agents.empty': '暂无子代理',
|
||||
'agents.hint': '子代理会话会在此按父会话分组展示。',
|
||||
'apps.empty': '暂无应用',
|
||||
'apps.hint': '工作区应用与外部入口将在此展示。',
|
||||
'messages.empty': '暂无消息',
|
||||
'messages.hint': '会话消息流与提醒将在此汇总。',
|
||||
'profile.model': '模型',
|
||||
'profile.provider': '提供商',
|
||||
'profile.unknown': '未知',
|
||||
'profile.hint': '账户与运行配置概览。',
|
||||
const zh = {
|
||||
explorer: '资源管理器',
|
||||
git: '源代码管理',
|
||||
terminal: '终端',
|
||||
editor: '编辑器',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
openGit: 'Git 面板',
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
close: '关闭',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
expandFullscreen: '展开为全屏',
|
||||
restoreFullscreen: '还原宽度',
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
refresh: '刷新',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
unsaved: '未保存',
|
||||
saveFailed: '保存失败',
|
||||
truncation: '文件过大,仅显示前 512KB',
|
||||
binary: '二进制文件,无法预览',
|
||||
loading: '加载中…',
|
||||
error: '加载失败',
|
||||
retry: '重试',
|
||||
splitLeft: '向左分栏',
|
||||
splitRight: '向右分栏',
|
||||
splitUp: '向上分栏',
|
||||
splitDown: '向下分栏',
|
||||
notRepo: '当前目录不是 git 仓库',
|
||||
noChanges: '没有变更',
|
||||
stage: '暂存',
|
||||
unstage: '取消暂存',
|
||||
stageAll: '全部暂存',
|
||||
unstageAll: '全部取消暂存',
|
||||
commitPlaceholder: '提交信息 (Ctrl+Enter)',
|
||||
commit: '提交',
|
||||
commitError: '提交失败',
|
||||
branch: '分支',
|
||||
checkoutError: '切换分支失败',
|
||||
history: '历史',
|
||||
changes: '变更',
|
||||
staged: '已暂存',
|
||||
unstaged: '未暂存',
|
||||
cancel: '取消',
|
||||
diffEmpty: '没有文本差异',
|
||||
diffLoadError: '加载差异失败',
|
||||
diffBinary: '二进制',
|
||||
diffAdded: '新增',
|
||||
diffDeleted: '删除',
|
||||
diffRenamed: '重命名',
|
||||
diffExpand: '展开其余 {count} 行',
|
||||
diffCollapse: '收起',
|
||||
discard: '放弃更改',
|
||||
discardTitle: '放弃更改',
|
||||
discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。',
|
||||
viewCommitDiff: '查看提交差异',
|
||||
copyShortHash: '复制短哈希',
|
||||
copyFullHash: '复制完整哈希',
|
||||
copySubject: '复制提交信息',
|
||||
revertCommit: '还原此提交',
|
||||
revertTitle: '还原此提交',
|
||||
revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。',
|
||||
cherryPickCommit: '捡取此提交',
|
||||
cherryPickTitle: '捡取此提交',
|
||||
cherryPickDesc: '将「{subject}」的更改应用到当前分支。',
|
||||
timeJustNow: '刚刚',
|
||||
timeMinutesAgo: '{n} 分钟前',
|
||||
timeHoursAgo: '{n} 小时前',
|
||||
timeYesterday: '昨天',
|
||||
loadMore: '加载更多',
|
||||
historyLoadError: '加载更多历史失败',
|
||||
produced: '本次产出',
|
||||
producedOpen: '在侧边栏中打开',
|
||||
disconnected: '终端连接断开,重连中…',
|
||||
exited: '终端进程已退出',
|
||||
noSession: '选择一个会话以使用侧边栏',
|
||||
pluginNotLoaded: '插件未加载,标签页暂不可用:',
|
||||
hiddenFiles: '隐藏文件',
|
||||
parent: '上级目录',
|
||||
copied: '已复制',
|
||||
copy: '复制',
|
||||
newFile: '新文件',
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
settingsPopup: '功能设置',
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
subagentCountRunning: '{count} 个子代理 · {running} 运行中',
|
||||
subagentDiagCorrupt: '目录损坏',
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
}
|
||||
|
||||
const en: Record<string, string> = {
|
||||
'tab.tasks.title': 'Tasks',
|
||||
'tab.agents.title': 'Agents',
|
||||
'tab.messages.title': 'Messages',
|
||||
'tab.apps.title': 'Apps',
|
||||
'tab.profile.title': 'Profile',
|
||||
'tasks.empty': 'No sessions yet',
|
||||
'tasks.running': 'running',
|
||||
'tasks.current': 'current',
|
||||
'tasks.subagent': 'subagent',
|
||||
'tasks.showInactive': 'Show inactive sessions',
|
||||
'agents.empty': 'No subagents yet',
|
||||
'agents.hint': 'Subagent sessions appear here, grouped by parent.',
|
||||
'apps.empty': 'No apps yet',
|
||||
'apps.hint': 'Workspace apps and external entry points appear here.',
|
||||
'messages.empty': 'No messages yet',
|
||||
'messages.hint': 'Session message flow and reminders are aggregated here.',
|
||||
'profile.model': 'Model',
|
||||
'profile.provider': 'Provider',
|
||||
'profile.unknown': 'Unknown',
|
||||
'profile.hint': 'Account and runtime configuration overview.',
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
explorer: 'Explorer',
|
||||
git: 'Source Control',
|
||||
terminal: 'Terminal',
|
||||
editor: 'Editor',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
openGit: 'Git panel',
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
close: 'Close',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
expandFullscreen: 'Expand to full width',
|
||||
restoreFullscreen: 'Restore width',
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
terminalRetry: 'Retry',
|
||||
preview: 'Preview',
|
||||
edit: 'Edit',
|
||||
refresh: 'Refresh',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
unsaved: 'Unsaved',
|
||||
saveFailed: 'Save failed',
|
||||
truncation: 'File too large — showing the first 512KB',
|
||||
binary: 'Binary file, preview unavailable',
|
||||
loading: 'Loading…',
|
||||
error: 'Failed to load',
|
||||
retry: 'Retry',
|
||||
splitLeft: 'Split left',
|
||||
splitRight: 'Split right',
|
||||
splitUp: 'Split up',
|
||||
splitDown: 'Split down',
|
||||
notRepo: 'This directory is not a git repository',
|
||||
noChanges: 'No changes',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage all',
|
||||
unstageAll: 'Unstage all',
|
||||
commitPlaceholder: 'Commit message (Ctrl+Enter)',
|
||||
commit: 'Commit',
|
||||
commitError: 'Commit failed',
|
||||
branch: 'Branch',
|
||||
checkoutError: 'Branch switch failed',
|
||||
history: 'History',
|
||||
changes: 'Changes',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
cancel: 'Cancel',
|
||||
diffEmpty: 'No text changes',
|
||||
diffLoadError: 'Failed to load diff',
|
||||
diffBinary: 'Binary',
|
||||
diffAdded: 'Added',
|
||||
diffDeleted: 'Deleted',
|
||||
diffRenamed: 'Renamed',
|
||||
diffExpand: 'Expand {count} more rows',
|
||||
diffCollapse: 'Collapse',
|
||||
discard: 'Discard changes',
|
||||
discardTitle: 'Discard changes',
|
||||
discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).',
|
||||
viewCommitDiff: 'View commit diff',
|
||||
copyShortHash: 'Copy short hash',
|
||||
copyFullHash: 'Copy full hash',
|
||||
copySubject: 'Copy subject',
|
||||
revertCommit: 'Revert commit',
|
||||
revertTitle: 'Revert commit',
|
||||
revertDesc: 'Create a new commit on the current branch that reverts "{subject}".',
|
||||
cherryPickCommit: 'Cherry-pick commit',
|
||||
cherryPickTitle: 'Cherry-pick commit',
|
||||
cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.',
|
||||
timeJustNow: 'just now',
|
||||
timeMinutesAgo: '{n} min ago',
|
||||
timeHoursAgo: '{n} h ago',
|
||||
timeYesterday: 'yesterday',
|
||||
loadMore: 'Load more',
|
||||
historyLoadError: 'Failed to load more history',
|
||||
produced: 'Produced',
|
||||
producedOpen: 'Open in sidebar',
|
||||
disconnected: 'Terminal disconnected, reconnecting…',
|
||||
exited: 'Terminal process exited',
|
||||
noSession: 'Select a conversation to use the sidebar',
|
||||
pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
|
||||
hiddenFiles: 'Hidden files',
|
||||
parent: 'Parent directory',
|
||||
copied: 'Copied',
|
||||
copy: 'Copy',
|
||||
newFile: 'New file',
|
||||
openEditor: 'Open editor',
|
||||
gitDetail: 'View change details',
|
||||
referenceFile: '@file',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
downloadToView: 'Download to view',
|
||||
officeTooLarge: 'File too large to preview',
|
||||
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||
officeEncrypted: 'Encrypted files are not supported',
|
||||
officeLoadFailed: 'Office preview component failed to load',
|
||||
previousSlide: 'Previous',
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
settingsPopup: 'Feature settings',
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
subagentRunning: 'Running',
|
||||
subagentInactive: 'Inactive',
|
||||
subagentModeOneShot: 'One-shot',
|
||||
subagentModeContinuable: 'Continuable',
|
||||
subagentCount: '{count} subagents',
|
||||
subagentCountRunning: '{count} subagents · {running} running',
|
||||
subagentDiagCorrupt: 'Corrupt',
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
}
|
||||
|
||||
const DICTS: Record<string, Record<string, string>> = { zh, en }
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
|
||||
/** The currently resolved active locale code ('zh' default). */
|
||||
let activeLocale = 'zh'
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
/** The active locale snapshot (uSES-safe: returns the same code string). */
|
||||
export function getActiveLocale(): string {
|
||||
return activeLocale
|
||||
}
|
||||
|
||||
export function subscribeLocale(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the calling component whenever the active locale changes.
|
||||
* Returns the active locale code; components re-resolve `t(key)` against it.
|
||||
*/
|
||||
export function useApesLocale(): string {
|
||||
return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale)
|
||||
}
|
||||
|
||||
/** Translate a key in the active locale; falls back to en, then the key. */
|
||||
export function t(key: string): string {
|
||||
const dict = DICTS[activeLocale] ?? en
|
||||
return dict[key] ?? en[key] ?? key
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dictionaries into the locale runtime AND subscribe to its
|
||||
* active-locale changes, keeping {@link activeLocale} in sync. Returns the
|
||||
* combined disposer (locale registrations + the snapshot subscription).
|
||||
*/
|
||||
export function registerLocales(locale: ApesLocaleService | undefined): () => void {
|
||||
if (!locale) return () => undefined
|
||||
const disposes = [
|
||||
locale.register(NS, 'zh', zh),
|
||||
locale.register(NS, 'en', en),
|
||||
]
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
// Seed the active locale from the runtime snapshot (uSES-safe read).
|
||||
activeLocale = locale.getSnapshot().active || 'zh'
|
||||
unsub = locale.subscribe(() => {
|
||||
const next = locale.getSnapshot().active || 'en'
|
||||
if (next !== activeLocale) {
|
||||
activeLocale = next
|
||||
notify()
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// Locale runtime unavailable (very early boot): stay on the default.
|
||||
}
|
||||
return () => {
|
||||
unsub?.()
|
||||
for (const d of disposes) {
|
||||
try {
|
||||
d()
|
||||
} catch {
|
||||
// Disposer already run (double-dispose) — ignore.
|
||||
}
|
||||
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
||||
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
|
||||
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
|
||||
let text = dict[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Whether the browser language is Chinese (used for selectors). */
|
||||
export function isZh(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
}
|
||||
|
||||
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = Math.floor((Date.now() - then) / 1000)
|
||||
if (seconds < 60) return t('timeJustNow')
|
||||
if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) })
|
||||
if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) })
|
||||
if (seconds < 172800) return t('timeYesterday')
|
||||
const date = new Date(then)
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from the editor host so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension (with leading dot) (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||
* bytes through the existing /sidebar/file media route and own their library
|
||||
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||
* TerminalView dispose discipline).
|
||||
*
|
||||
* Errors degrade to the same download-button affordance the binary
|
||||
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||
* leaves the user with a way to get the file.
|
||||
*
|
||||
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||
// contains all of them).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Path projection helpers shared by the explorer rows: a path relative to
|
||||
* the session cwd (for the @-reference button and "copy relative path").
|
||||
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
||||
* to '/' before comparison.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The path relative to the session's working directory.
|
||||
* @param cwd - the explorer root (absolute).
|
||||
* @param path - an absolute entry path from the fs-tree.
|
||||
* @returns the relative path with '/' separators ('.' for the cwd itself),
|
||||
* or `path` unchanged when it lies outside the cwd.
|
||||
*
|
||||
* The prefix test is case-insensitive: Windows paths (and macOS's
|
||||
* case-insensitive volumes) may arrive with different casing than the cwd
|
||||
* row, and the containment decision must not depend on it. The returned
|
||||
* relative text keeps the caller's own casing.
|
||||
*/
|
||||
export function relativeTo(cwd: string, path: string): string {
|
||||
const base = cwd.replace(/[\\/]+$/, '')
|
||||
const norm = (value: string): string => value.replace(/\\/g, '/')
|
||||
const nBase = norm(base)
|
||||
const nPath = norm(path)
|
||||
if (nPath === nBase) return '.'
|
||||
if (nPath.toLowerCase().startsWith(`${nBase.toLowerCase()}/`)) return nPath.slice(nBase.length + 1)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Pure PDF extension dispatch, separate from React for unit testing. */
|
||||
export function isPdfExt(ext: string): boolean {
|
||||
return ext === '.pdf'
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Client-side read of the user-facing "Side card" preferences. The host owns
|
||||
* the namespace through the settings seam (in-process); the DSH settings RPC
|
||||
* domain only serves allowlisted namespaces to configuration clients, so the
|
||||
* client reads and writes THIS namespace through the plugin's own fenced
|
||||
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
|
||||
* (route rejected, namespace absent, a field of the wrong type, a value out
|
||||
* of the contract range) falls back to the schema defaults — the side card
|
||||
* must keep working exactly as composed when the settings surface is missing.
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
* @returns validated prefs (always well-formed).
|
||||
*/
|
||||
export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
openByDefault: typeof record.openByDefault === 'boolean'
|
||||
? record.openByDefault
|
||||
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
|
||||
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
|
||||
? clampWidthPercent(record.defaultWidthPercent)
|
||||
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
|
||||
* survive; a non-object or a non-boolean entry falls back to the empty map /
|
||||
* drops the entry — an absent key means the feature stays enabled.
|
||||
*/
|
||||
function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, boolean> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof item === 'boolean') out[key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
* @returns validated prefs, or the schema defaults when the route rejects,
|
||||
* the namespace is absent, or a stored value violates the contract.
|
||||
*/
|
||||
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
|
||||
try {
|
||||
const view = await settings.settingsGet()
|
||||
return parsePrefs(view.value)
|
||||
} catch {
|
||||
// Transport/fence rejection or a malformed response: keep the defaults.
|
||||
return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Pure derivation of one turn's produced files from finalized conversation
|
||||
* nodes — a structural replica of ui-deliverables' `producedForClosing`
|
||||
* (the mutation tools' follow-along `locations`, by render intent: a diff
|
||||
* card or a generic edit card; reads/deletes/failures produce nothing).
|
||||
* Kept dependency-free so the takeover logic is unit-testable and the
|
||||
* replica is easy to diff against upstream when it drifts.
|
||||
*/
|
||||
|
||||
/** Paths a tool-result view reports as produced, by render intent. */
|
||||
export function producedPaths(view: unknown): readonly string[] {
|
||||
if (view === null || typeof view !== 'object') return []
|
||||
const record = view as { card?: unknown; kind?: unknown; locations?: unknown }
|
||||
const isEdit = record.card === 'diff' || (record.card === 'generic' && record.kind === 'edit')
|
||||
if (!isEdit) return []
|
||||
if (!Array.isArray(record.locations)) return []
|
||||
const paths: string[] = []
|
||||
for (const location of record.locations) {
|
||||
if (location !== null && typeof location === 'object' && typeof (location as { path?: unknown }).path === 'string') {
|
||||
paths.push((location as { path: string }).path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Files produced by the turn the assistant at `seq` closes. Accumulation
|
||||
* resets on turn boundaries (a user message, or a node reporting a different
|
||||
* turn number); paths keep first-seen order and appear once.
|
||||
* @param nodes - snapshot nodes in surface order (structural, unknown-safe).
|
||||
* @param seq - the closing assistant's seq (the render site's anchor).
|
||||
* @returns produced paths; empty when the turn wrote nothing.
|
||||
*/
|
||||
export function producedForClosing(nodes: readonly unknown[], seq: number): readonly string[] {
|
||||
let pending: string[] = []
|
||||
let seen = new Set<string>()
|
||||
let turn: number | undefined
|
||||
for (const node of nodes) {
|
||||
if (node === null || typeof node !== 'object') continue
|
||||
const record = node as { kind?: unknown; isError?: unknown; callView?: unknown; turn?: unknown; seq?: unknown }
|
||||
if (record.kind === 'tool-result') {
|
||||
if (record.isError === true) continue
|
||||
for (const path of producedPaths(record.callView)) {
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
pending.push(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (record.kind === 'user') {
|
||||
turn = undefined
|
||||
pending = []
|
||||
seen = new Set()
|
||||
} else if (typeof record.turn === 'number') {
|
||||
if (turn !== undefined && record.turn !== turn) {
|
||||
pending = []
|
||||
seen = new Set()
|
||||
}
|
||||
turn = record.turn
|
||||
}
|
||||
if (record.kind === 'assistant' && record.seq === seq) return pending
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the turn-tail chain only when the closing turn produced files.
|
||||
* @param owner - the turn-tail owner currency ({nodes, seq}).
|
||||
* @returns produced paths as the matched value, or null to decline.
|
||||
*/
|
||||
export function selectProducedFiles(owner: unknown): readonly string[] | null {
|
||||
const record = owner as { nodes?: unknown; seq?: unknown } | null
|
||||
if (record === null || typeof record !== 'object') return null
|
||||
if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null
|
||||
const paths = producedForClosing(record.nodes, record.seq)
|
||||
return paths.length === 0 ? null : paths
|
||||
}
|
||||
|
||||
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
|
||||
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
|
||||
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
|
||||
if (absolute) return path
|
||||
const base = cwd ?? ''
|
||||
if (base === '') return path
|
||||
const separator = base.includes('\\') ? '\\' : '/'
|
||||
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* The BetterSidebar client service: a registry that external plugins use
|
||||
* to contribute sidebar tab types and file previewers. The service is
|
||||
* published to the cordis context as `ctx.betterSidebar` (see
|
||||
* {@link ../context-types.ts}); consumers declare it in `inject` and call
|
||||
* `registerTab` / `registerFileViewer`, both returning a disposer that
|
||||
* cordis auto-invokes on fiber disposal (HMR-safe).
|
||||
*
|
||||
* Design notes:
|
||||
* - The registry is synchronous-snapshot (Map + listener set) so React
|
||||
* can read it through `useSyncExternalStore` without tearing.
|
||||
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
|
||||
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
|
||||
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
|
||||
* `single: true` is sugar for `dedupeKey: () => id`.
|
||||
* - `createTab` lets a descriptor own tab instantiation (the terminal
|
||||
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
|
||||
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
|
||||
* per descriptor it tries `detect` first (when `head` bytes are given),
|
||||
* then `exts`; `exts: []` is a catch-all that matches any path.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
/** Row title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** The explorer's expanded directory set (ExplorerView). */
|
||||
expanded?: string[]
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Describes one kind of sidebar tab (builtins register themselves too). */
|
||||
export interface TabDescriptor {
|
||||
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + menu sort order (ascending); default 100. */
|
||||
order?: number
|
||||
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
|
||||
hidden?: boolean
|
||||
/**
|
||||
* + menu disabled predicate (e.g. terminal at capacity). Receives the
|
||||
* session scope and the live sidebar state (counts, expansions).
|
||||
*/
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
|
||||
* (opening the tab focuses an existing one of the same type instead of
|
||||
* creating a duplicate). An explicit `dedupeKey` always wins when both
|
||||
* are given. Builtins: explorer/git/subagent use `single: true`.
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
|
||||
* tab's key focuses the existing one instead of creating a new one.
|
||||
* Returning `undefined` means "no dedup — always open a new tab".
|
||||
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
|
||||
* (openDiffTab mints change-derived ids).
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* Custom tab creation (minting the `SidebarTab` and any state patches).
|
||||
* Return `null` to refuse creation. The terminal builtin uses this to
|
||||
* mint `terminal:<n>` ids and bump `nextTerminal`.
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
/** How the host loads a file's bytes for one viewer. */
|
||||
export type FileFetchStrategy =
|
||||
| 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
|
||||
| 'fsRead' // text read through /sidebar/api fs.read
|
||||
| 'mediaUrl' // the viewer gets a media URL string
|
||||
| 'custom' // the viewer's load() fetches its own bytes
|
||||
| 'binary-download' // show a download button (no client-side renderer)
|
||||
|
||||
/** Props every file viewer component receives. */
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
|
||||
viewerId: string
|
||||
/** fsRead text content (fetchStrategy='fsRead'). */
|
||||
content?: string
|
||||
truncated?: boolean
|
||||
/** mediaUrl for the path (fetchStrategy='mediaUrl'). */
|
||||
mediaUrl?: string
|
||||
/** custom load() return value (fetchStrategy='custom'). */
|
||||
customData?: unknown
|
||||
}
|
||||
|
||||
/** Describes one file previewer (builtins register themselves too). */
|
||||
export interface FileViewerDescriptor {
|
||||
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
|
||||
id: string
|
||||
/** Display name for the settings inventory (falls back to `id` when absent). */
|
||||
title?: string | (() => string)
|
||||
/** Icon shown in the settings inventory. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
|
||||
exts: readonly string[]
|
||||
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
|
||||
priority?: number
|
||||
fetchStrategy: FileFetchStrategy
|
||||
/**
|
||||
* Content sniff: when `head` bytes are available the descriptor's `detect`
|
||||
* is consulted before its `exts` (per-descriptor, in priority order).
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/**
|
||||
* Whether a tab type is enabled in the side card prefs. An absent
|
||||
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
|
||||
* disables the type (hidden from the + menu, `openTab` refuses, and
|
||||
* derived flows gate on it).
|
||||
*/
|
||||
isTabEnabled(id: string): boolean
|
||||
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/**
|
||||
* Find a file viewer for a path (priority desc; detect first, then exts).
|
||||
* Disabled viewers are skipped, so files fall through to the next match.
|
||||
*/
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
* to the store's reducer. One instance per client plugin activation.
|
||||
*/
|
||||
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
|
||||
const tabs = new Map<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const notify = (): void => {
|
||||
for (const fn of [...listeners]) fn()
|
||||
}
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
const registerTab = (descriptor: TabDescriptor): (() => void) => {
|
||||
if (tabs.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
|
||||
}
|
||||
tabs.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (tabs.get(descriptor.id) === descriptor) {
|
||||
tabs.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
|
||||
if (viewers.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
|
||||
}
|
||||
viewers.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (viewers.get(descriptor.id) === descriptor) {
|
||||
viewers.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
|
||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||
|
||||
// The enable switches come from the user's side card prefs (the shared
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Single pass in priority order (descending; stable for equal
|
||||
// priorities — insertion order). Each descriptor gets first refusal in
|
||||
// its own turn: `detect` (when head bytes are available) beats its own
|
||||
// `exts`, and `exts: []` is a catch-all matching any path — so the
|
||||
// catch-all `code` viewer (-100) only sees paths no higher-priority
|
||||
// descriptor claimed. Disabled viewers are skipped entirely.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (!isViewerEnabled(v.id)) continue
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
|
||||
// paths it never sniffed (a magic-number viewer must not swallow
|
||||
// every file before the real viewers get their turn).
|
||||
if (v.exts.length === 0) continue
|
||||
} else if (v.exts.length === 0) {
|
||||
// Blind catch-all (no detect) claims anything; a sniff-only
|
||||
// catch-all (detect defined, no head yet) yields this round.
|
||||
if (v.detect === undefined) return v
|
||||
continue
|
||||
}
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
if (!isTabEnabled(seed.type)) {
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
if (descriptor.createTab !== undefined) {
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
const next = applyDedupe(state, result.tab, descriptor)
|
||||
return result.patch !== undefined ? { ...next, ...result.patch } : next
|
||||
}
|
||||
const tab: SidebarTab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
// A caller-provided title wins (the editor shows the file name);
|
||||
// otherwise the descriptor's (possibly i18n) title is the default.
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
}
|
||||
return applyDedupe(state, tab, descriptor)
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
store.reduce((state) => {
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registerTab,
|
||||
registerFileViewer,
|
||||
getTabs,
|
||||
getFileViewers,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
|
||||
* same type exists, focus it; otherwise land the tab through
|
||||
* `openTabInActivePane` (the id safety net + active-pane landing are that
|
||||
* reducer's job — not re-implemented here).
|
||||
* `single: true` resolves to the id-key sugar when no explicit key is given.
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). */
|
||||
function findPaneIdOf(state: SidebarState, tabId: string): string {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
|
||||
}
|
||||
return state.activePane ?? ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The split-pane workbench: renders the recursive split tree. A split lays
|
||||
* children out row- or column-wise with draggable dividers (fractional
|
||||
* sizes); a leaf renders its tab strip plus the active tab's content.
|
||||
*
|
||||
* Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
|
||||
* over a pane, a drop overlay shows five zones — four edges (left/right/up/
|
||||
* down) that split the pane with the tab in a fresh leaf, and the center
|
||||
* that merges the tab into the pane. The tree and all operations live in
|
||||
* state.ts; this file is pure presentation over them.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
import type { DropZone } from './state.ts'
|
||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
||||
export interface WorkbenchActions {
|
||||
closeTab: (paneId: string, tabId: string) => void
|
||||
activateTab: (paneId: string, tabId: string) => void
|
||||
/** Make a pane the target of newly opened tabs (click focus). */
|
||||
focusPane: (paneId: string) => void
|
||||
/** VSCode drag gesture: edge → split the target pane, center → merge. */
|
||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
|
||||
/** Reorder within a pane (drop onto another tab inserts before it). */
|
||||
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
|
||||
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
|
||||
}
|
||||
|
||||
/** One divider: pointer-capture drag translating px deltas into fractions.
|
||||
* Deltas are incremental — each move reports the displacement since the
|
||||
* previous move — because the store adds every reported delta to the pane
|
||||
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
|
||||
* move and the divider would run away from the cursor. */
|
||||
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
|
||||
const { dir, onResize } = props
|
||||
// `last` is the previous pointer position while dragging; `size` is the
|
||||
// container size at pointer-down (constant during the drag), used to
|
||||
// normalize the px delta into a fraction.
|
||||
const last = useRef({ x: 0, y: 0, size: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
const box = event.currentTarget.parentElement?.getBoundingClientRect()
|
||||
last.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
|
||||
}
|
||||
setDragging(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
|
||||
onResize(delta / Math.max(1, last.current.size))
|
||||
last.current.x = event.clientX
|
||||
last.current.y = event.clientY
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDragging(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
|
||||
function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
|
||||
const rect = pane.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return 'center'
|
||||
const x = (event.clientX - rect.left) / rect.width
|
||||
const y = (event.clientY - rect.top) / rect.height
|
||||
if (x < 0.25) return 'left'
|
||||
if (x > 0.75) return 'right'
|
||||
if (y < 0.25) return 'up'
|
||||
if (y > 0.75) return 'down'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
/** The icon of one openable type card (mirror of the + menu options). */
|
||||
/**
|
||||
* An empty pane's welcome cards: the openable types as cards, clicked to
|
||||
* open (instead of a bare "this pane is empty" message).
|
||||
*/
|
||||
function PaneEmptyCards(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
onNewTab: (optionId: string) => void
|
||||
}) {
|
||||
const { newTabOptions, onNewTab } = props
|
||||
return (
|
||||
<div className={css.paneEmptyCards}>
|
||||
{newTabOptions.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={css.paneCard}
|
||||
disabled={option.disabled === true}
|
||||
title={option.label}
|
||||
onClick={() => { onNewTab(option.id) }}
|
||||
>
|
||||
{option.icon ?? null}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
|
||||
function LeafView(props: {
|
||||
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setDropZone(null) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
const zone = zoneAt(event, event.currentTarget)
|
||||
setDropZone(zone)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
// Only clear when the pointer left the pane entirely (not onto a child).
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setDropZone(null)
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const zone = dropZone ?? zoneAt(event, event.currentTarget)
|
||||
setDropZone(null)
|
||||
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
|
||||
if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
|
||||
}}
|
||||
>
|
||||
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
|
||||
{/*
|
||||
The tab strip renders even for an empty pane: the + menu must stay
|
||||
reachable when the pane has no tabs (fresh split, or the last tab was
|
||||
dragged out), so a new tab can always be created or dragged in.
|
||||
*/}
|
||||
<TabBar
|
||||
paneId={leaf.id}
|
||||
tabs={leaf.tabs}
|
||||
active={leaf.active}
|
||||
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
|
||||
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
||||
onNewTab={onNewTab}
|
||||
newTabOptions={newTabOptions}
|
||||
getTabIcon={getTabIcon}
|
||||
onDropTab={(payload, before) => {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
}}
|
||||
/>
|
||||
{leaf.tabs.length > 0 ? (
|
||||
/*
|
||||
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
|
||||
never tears down the content: a terminal keeps its pty connection
|
||||
and scrollback, an editor keeps its CodeMirror view and unsaved
|
||||
draft, explorer/git keep their loaded data. The unmount (and the
|
||||
terminal's close frame) happens only when a tab is truly closed.
|
||||
*/
|
||||
<div className={css.paneContent}>
|
||||
{leaf.tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
|
||||
>
|
||||
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recursive node renderer. */
|
||||
function NodeView(props: {
|
||||
node: SplitNode
|
||||
state: SidebarState
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
leaf={node}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isRow = node.dir === 'row'
|
||||
return (
|
||||
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
|
||||
{node.children.map((child, index) => (
|
||||
<Fragment key={child.id}>
|
||||
{index > 0 && (
|
||||
<Divider
|
||||
dir={node.dir}
|
||||
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={css.splitChild}
|
||||
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
||||
>
|
||||
<NodeView
|
||||
node={child}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The workbench: the split tree filling the sidebar body. */
|
||||
export function Workbench(props: {
|
||||
state: SidebarState
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
node={state.splits}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* Per-session sidebar state: the panel geometry, the split-pane workbench
|
||||
* tree, open tabs, and the explorer expansion set. One state instance per
|
||||
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
|
||||
* a reload restores the exact layout of the session it belongs to — switching
|
||||
* conversations swaps the whole state (memory + isolation).
|
||||
*
|
||||
* The split tree is a recursive structure: a leaf holds a tab group, a split
|
||||
* divides the space row- or column-wise with fractional sizes. All tree
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/**
|
||||
* Tab type identifier. Builtins register their ids (explorer / git / editor
|
||||
* / terminal / subagent / diff) through the sidebar service; external
|
||||
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
|
||||
* the registry stays open.
|
||||
*/
|
||||
export type TabType = string
|
||||
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
|
||||
* `diff` carries the change a diff tab shows. */
|
||||
export interface SidebarTab {
|
||||
id: string
|
||||
type: TabType
|
||||
title: string
|
||||
path?: string
|
||||
diff?: SidebarDiffRef
|
||||
}
|
||||
|
||||
/** A tab group. */
|
||||
export interface SidebarLeaf {
|
||||
kind: 'leaf'
|
||||
id: string
|
||||
tabs: SidebarTab[]
|
||||
active: string | null
|
||||
}
|
||||
|
||||
/** A recursive split between child panes (fractional sizes summing to 1). */
|
||||
export interface SidebarSplit {
|
||||
kind: 'split'
|
||||
id: string
|
||||
dir: 'row' | 'col'
|
||||
sizes: number[]
|
||||
children: SplitNode[]
|
||||
}
|
||||
|
||||
export type SplitNode = SidebarLeaf | SidebarSplit
|
||||
|
||||
/** The full per-session state. */
|
||||
export interface SidebarState {
|
||||
panelOpen: boolean
|
||||
width: number
|
||||
/** The pane receiving newly opened tabs (last pane the user touched). */
|
||||
activePane: string | null
|
||||
/** Monotonic terminal tab counter (ids survive reloads). */
|
||||
nextTerminal: number
|
||||
/** Explorer expansion set (absolute directory paths). */
|
||||
expanded: string[]
|
||||
splits: SplitNode
|
||||
}
|
||||
|
||||
export const PANEL_MIN = 280
|
||||
export const PANEL_MAX = 640
|
||||
export const PANEL_DEFAULT = 400
|
||||
export const TAB_MAX_WIDTH = 160
|
||||
|
||||
let nextIdCounter = 0
|
||||
/** Unique pane/tab id within one state instance. */
|
||||
function uid(prefix: string): string {
|
||||
nextIdCounter += 1
|
||||
return `${prefix}:${nextIdCounter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest numeric suffix across a raw persisted state's counter ids
|
||||
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||||
* resets on every reload, so a split minted AFTER a reload would collide
|
||||
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
||||
* mapLeaf would then visit BOTH leaves and every open would land in both
|
||||
* panes of the split. Seeding the counter past the persisted ids keeps
|
||||
* fresh ids disjoint.
|
||||
*/
|
||||
function maxCounterId(parsed: unknown): number {
|
||||
let max = 0
|
||||
const consider = (id: unknown): void => {
|
||||
if (typeof id !== 'string') return
|
||||
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
||||
if (match !== null) max = Math.max(max, Number(match[1]))
|
||||
}
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
consider(record.id)
|
||||
if (Array.isArray(record.tabs)) {
|
||||
for (const tab of record.tabs) {
|
||||
if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(record.children)) {
|
||||
for (const child of record.children) walk(child)
|
||||
}
|
||||
}
|
||||
walk((parsed as Record<string, unknown> | null)?.splits)
|
||||
return max
|
||||
}
|
||||
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
return {
|
||||
panelOpen,
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
nextTerminal: 1,
|
||||
expanded: [],
|
||||
splits: leaf,
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk the tree and apply `visit` to the leaf with the given id. */
|
||||
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
|
||||
if (node.kind === 'leaf') {
|
||||
if (node.id === paneId) {
|
||||
const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
|
||||
visit(copy)
|
||||
return copy
|
||||
}
|
||||
return node
|
||||
}
|
||||
const split = node
|
||||
return {
|
||||
...split,
|
||||
sizes: [...split.sizes],
|
||||
children: split.children.map(child => mapLeaf(child, paneId, visit)),
|
||||
}
|
||||
}
|
||||
|
||||
/** The first leaf of the tree (fallback pane when activePane is gone). */
|
||||
export function firstLeaf(node: SplitNode): SidebarLeaf {
|
||||
if (node.kind === 'leaf') return node
|
||||
return firstLeaf(node.children[0]!)
|
||||
}
|
||||
|
||||
/** Find the leaf containing a tab id, if any. */
|
||||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||||
if (node.kind === 'leaf') {
|
||||
return node.tabs.some(tab => tab.id === tabId) ? node : undefined
|
||||
}
|
||||
for (const child of node.children) {
|
||||
const found = leafWithTab(child, tabId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** All leaves of the tree, depth-first. */
|
||||
export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||||
if (node.kind === 'leaf') return [node]
|
||||
return node.children.flatMap(allLeaves)
|
||||
}
|
||||
|
||||
/** Whether a tab exists anywhere in a state tree (any pane). */
|
||||
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
|
||||
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
}
|
||||
|
||||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||||
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
|
||||
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
return mapLeaf(node, paneId, (leaf) => {
|
||||
const target: SidebarLeaf = { ...leaf }
|
||||
const split: SidebarSplit = {
|
||||
kind: 'split',
|
||||
id: uid('split'),
|
||||
dir,
|
||||
sizes: [0.5, 0.5],
|
||||
children: [target, fresh],
|
||||
}
|
||||
Object.assign(leaf, split)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a leaf by inserting a fresh leaf holding `tab` beside it — the
|
||||
* VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
|
||||
* left/right, 'col' for up/down); `front` places the new leaf first (left/
|
||||
* up) or second (right/down).
|
||||
* @returns the new tree plus the fresh leaf's id (the drop's active pane).
|
||||
*/
|
||||
export function insertLeafAt(
|
||||
node: SplitNode,
|
||||
paneId: string,
|
||||
dir: 'row' | 'col',
|
||||
tab: SidebarTab,
|
||||
front: boolean,
|
||||
): { node: SplitNode; leafId: string } {
|
||||
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
|
||||
const leafId = fresh.id
|
||||
const next = mapLeaf(node, paneId, (leaf) => {
|
||||
const target: SidebarLeaf = { ...leaf }
|
||||
const split: SidebarSplit = {
|
||||
kind: 'split',
|
||||
id: uid('split'),
|
||||
dir,
|
||||
sizes: [0.5, 0.5],
|
||||
children: front ? [fresh, target] : [target, fresh],
|
||||
}
|
||||
Object.assign(leaf, split)
|
||||
})
|
||||
return { node: next, leafId }
|
||||
}
|
||||
|
||||
/** Where a tab drop lands on a pane: an edge creates a split, center merges. */
|
||||
export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
|
||||
|
||||
/**
|
||||
* The VSCode drag gesture: move a tab out of its pane and either merge it
|
||||
* into the target pane (center) or split the target pane with the tab in a
|
||||
* fresh leaf (edge). The source pane collapses when it empties.
|
||||
*/
|
||||
export function moveTabToEdge(
|
||||
state: SidebarState,
|
||||
fromPane: string,
|
||||
tabId: string,
|
||||
toPane: string,
|
||||
zone: DropZone,
|
||||
): SidebarState {
|
||||
if (fromPane === toPane && zone === 'center') {
|
||||
// Dropped back onto its own pane's center: reorder to the end.
|
||||
return moveTab(state, fromPane, tabId, toPane, -1)
|
||||
}
|
||||
const source = leafWithTab(state.splits, tabId)
|
||||
if (source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
let emptied = false
|
||||
let splits = mapLeaf(state.splits, source.id, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
if (emptied) splits = removeLeafAt(splits, source.id)
|
||||
if (zone === 'center') {
|
||||
splits = mapLeaf(splits, toPane, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
})
|
||||
return { ...state, splits, activePane: toPane }
|
||||
}
|
||||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||||
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a leaf from the tree. A split left with one child promotes that
|
||||
* child; removing the last leaf yields an empty leaf.
|
||||
*/
|
||||
export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
|
||||
if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
|
||||
const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
|
||||
if (children.length === node.children.length) {
|
||||
return {
|
||||
...node,
|
||||
sizes: [...node.sizes],
|
||||
children: node.children.map(child => removeLeafAt(child, paneId)),
|
||||
}
|
||||
}
|
||||
if (children.length === 1) return children[0]!
|
||||
return { ...node, sizes: [...node.sizes], children }
|
||||
}
|
||||
|
||||
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
|
||||
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
let emptied = false
|
||||
const splits = mapLeaf(state.splits, paneId, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
|
||||
}
|
||||
|
||||
/** Activate a tab in its pane. */
|
||||
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
return {
|
||||
...state,
|
||||
activePane: paneId,
|
||||
splits: mapLeaf(state.splits, paneId, (leaf) => {
|
||||
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
|
||||
* this reducer only handles the id-based safety net (reconcile and
|
||||
* openDiffTab already check existence before calling) and the landing
|
||||
* itself — the service's dedupe path delegates here after its dedupeKey
|
||||
* check misses.
|
||||
*/
|
||||
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
// A stale activePane (its pane was closed since) must not swallow the
|
||||
// open: fall back to the first pane instead of dropping the tab.
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
// Id-based safety net: if a tab with the same id exists, focus it.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
splits: mapLeaf(state.splits, targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a tab from one pane to another (insert at index; -1 appends). */
|
||||
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
|
||||
let moved: SidebarTab | undefined
|
||||
let emptied = false
|
||||
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
|
||||
const found = leaf.tabs.find(tab => tab.id === tabId)
|
||||
if (found === undefined) return
|
||||
moved = found
|
||||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
if (moved === undefined) return state
|
||||
if (emptied) splits = removeLeafAt(splits, fromPane)
|
||||
splits = mapLeaf(splits, toPane, (leaf) => {
|
||||
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
|
||||
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
|
||||
leaf.active = moved!.id
|
||||
})
|
||||
return { ...state, splits, activePane: toPane }
|
||||
}
|
||||
|
||||
/** Split the active pane (or the pane containing the active tab). */
|
||||
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
|
||||
const paneId = state.activePane ?? firstLeaf(state.splits).id
|
||||
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a diff tab the VSCode way: an existing instance of the same change is
|
||||
* focused wherever it lives; otherwise the tab joins the first pane that
|
||||
* already holds diff tabs (diff panes are sticky — repeated clicks stack
|
||||
* there); on the FIRST diff of a layout the source pane splits vertically so
|
||||
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
|
||||
*
|
||||
* This is split-tree placement surgery, not registry dispatch: the diff tab
|
||||
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
|
||||
* check below is exactly that rule — the two agree by construction (asserted
|
||||
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
|
||||
* the id check is the per-change dedupe.
|
||||
* @returns the new state, with the diff pane active.
|
||||
*/
|
||||
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
|
||||
const existingLeaf = leafWithTab(state.splits, tab.id)
|
||||
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
|
||||
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
|
||||
if (diffLeaf !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
activePane: diffLeaf.id,
|
||||
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
// First diff: split the source pane, the diff tab in the new LOWER leaf.
|
||||
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
|
||||
// regular open path instead of dropping the tab into an orphaned leaf.)
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/** Toggle the panel open/closed (opening restores the previous layout). */
|
||||
export function togglePanel(state: SidebarState): SidebarState {
|
||||
return { ...state, panelOpen: !state.panelOpen }
|
||||
}
|
||||
|
||||
/** Set the panel width (clamped to the contract range; the upper bound is
|
||||
* the viewport so the fullscreen expansion can fill the window). */
|
||||
export function setWidth(state: SidebarState, width: number): SidebarState {
|
||||
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
|
||||
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
|
||||
}
|
||||
|
||||
/** Toggle a directory in the explorer expansion set. */
|
||||
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
|
||||
const expanded = state.expanded.includes(path)
|
||||
? state.expanded.filter(item => item !== path)
|
||||
: [...state.expanded, path]
|
||||
return { ...state, expanded }
|
||||
}
|
||||
|
||||
/** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
|
||||
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
|
||||
if (node.kind === 'leaf') return node
|
||||
if (node.id === splitId) {
|
||||
const sizes = [...node.sizes]
|
||||
const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
|
||||
const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
|
||||
sizes[index] = left
|
||||
sizes[index + 1] = right
|
||||
return { ...node, sizes }
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
sizes: [...node.sizes],
|
||||
children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||||
export const AGENT_TAB_PREFIX = 'agent:'
|
||||
|
||||
/** Whether a tab id refers to an agent-owned terminal. */
|
||||
export function isAgentTabId(tabId: string): boolean {
|
||||
return tabId.startsWith(AGENT_TAB_PREFIX)
|
||||
}
|
||||
|
||||
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
|
||||
export function agentUuidOf(tabId: string): string {
|
||||
return tabId.slice(AGENT_TAB_PREFIX.length)
|
||||
}
|
||||
|
||||
/** Build the sidebar tab id for one agent terminal uuid. */
|
||||
export function agentTabId(uuid: string): string {
|
||||
return `${AGENT_TAB_PREFIX}${uuid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
|
||||
* The host pushes the current list of agent terminals (created by the model
|
||||
* through the `terminal_create` tool) over a dedicated WebSocket; this
|
||||
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
|
||||
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
|
||||
* WS close frame that kills the pty, which fires a change, which converges
|
||||
* the view. Idempotent: a no-op when the lists already match.
|
||||
* @param state - the current per-session sidebar state.
|
||||
* @param agentTerminals - the live agent terminal snapshots from the host.
|
||||
* @returns the next state (or the same reference if no change was needed).
|
||||
*/
|
||||
export function reconcileAgentTerminals(
|
||||
state: SidebarState,
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
||||
): SidebarState {
|
||||
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
|
||||
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
|
||||
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
|
||||
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
|
||||
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
|
||||
if (toAdd.length === 0 && toRemove.length === 0) return state
|
||||
// Remove tabs whose uuids vanished from the server list (the agent closed
|
||||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
|
||||
let splits = state.splits
|
||||
for (const tab of toRemove) {
|
||||
const leaf = leafWithTab(splits, tab.id)
|
||||
if (leaf !== undefined) {
|
||||
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
|
||||
}
|
||||
}
|
||||
// Add tabs for new uuids (the agent created a terminal). They land in the
|
||||
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
|
||||
let next: SidebarState = { ...state, splits }
|
||||
for (const terminal of toAdd) {
|
||||
const tab: SidebarTab = {
|
||||
id: agentTabId(terminal.uuid),
|
||||
type: 'terminal',
|
||||
title: terminal.title,
|
||||
}
|
||||
next = openTabInActivePane(next, tab)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// ── The per-session store ──────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||||
|
||||
/** Immutable snapshot handed to React (replaced only on real changes). */
|
||||
export interface SidebarSnapshot {
|
||||
sessionId: string | undefined
|
||||
state: SidebarState | undefined
|
||||
/**
|
||||
* The current side card prefs. Carried IN the snapshot (not a separate
|
||||
* subscription) so prefs changes re-render the consumers that gate on
|
||||
* them — the + menu hides a tab type the moment its switch flips.
|
||||
*/
|
||||
prefs: SidebarPrefs
|
||||
}
|
||||
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
* clamped to the panel floor (a tiny percent must stay usable) and to the
|
||||
* viewport (a large one must never cover the whole window). */
|
||||
export function defaultWidthFor(viewport: number, percent: number): number {
|
||||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||||
}
|
||||
|
||||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
// Seed the uid counter past the persisted ids (it resets on reload);
|
||||
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||||
nextIdCounter = maxCounterId(parsed)
|
||||
const sanitized = sanitizeState(parsed)
|
||||
if (sanitized !== undefined) return sanitized
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation of one persisted state. A malformed or stale shape
|
||||
* (older layouts, hand-edited storage) must fall back to the default instead
|
||||
* of crashing the panel on every reload; the restored width is also clamped
|
||||
* to the current viewport so a stale fullscreen width can never crush the
|
||||
* app shell (margin-right larger than the window) or cover the whole screen.
|
||||
* @returns a clean state, or undefined to fall back to the default.
|
||||
*/
|
||||
export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
if (parsed === null || typeof parsed !== 'object') return undefined
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (typeof record.panelOpen !== 'boolean') return undefined
|
||||
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
|
||||
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||||
const reid = new Map<string, string>()
|
||||
const splits = sanitizeNode(record.splits, new Set(), reid)
|
||||
if (splits === undefined) return undefined
|
||||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||||
return {
|
||||
panelOpen: record.panelOpen,
|
||||
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
||||
// A stale duplicate pane id may have been re-ided; follow the rename so
|
||||
// new tabs still land in the pane the user was using.
|
||||
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
|
||||
nextTerminal: record.nextTerminal,
|
||||
expanded: record.expanded as string[],
|
||||
splits,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tree node id, deduplicated against the ids already seen in this
|
||||
* state. Duplicates are exactly the pre-seeding counter-reset corruption
|
||||
* (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
|
||||
* keeping both would make mapLeaf visit two leaves at once and every open
|
||||
* would land in both panes, so the repeat gets a fresh id.
|
||||
* @returns the id to use (the original, or a fresh uid for repeats).
|
||||
*/
|
||||
function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
return id
|
||||
}
|
||||
const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
|
||||
const fresh = uid(prefix)
|
||||
seen.add(fresh)
|
||||
reid.set(id, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
||||
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined
|
||||
const record = node as Record<string, unknown>
|
||||
if (record.kind === 'leaf') {
|
||||
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
|
||||
const tabs: SidebarTab[] = []
|
||||
let droppedDiff = false
|
||||
for (const tab of record.tabs) {
|
||||
if (tab === null || typeof tab !== 'object') return undefined
|
||||
const candidate = tab as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
|
||||
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
|
||||
// they never survive a reload, so a stale ref (e.g. a wrong staged
|
||||
// side persisted by an older build) cannot resurface as a dead tab
|
||||
// showing "no text changes" after every refresh.
|
||||
if (candidate.type === 'diff') {
|
||||
droppedDiff = true
|
||||
continue
|
||||
}
|
||||
// Tab types are an open set (external plugins register their own);
|
||||
// accept any string type here — an unregistered type renders an
|
||||
// <OrphanedTab/> at view time and recovers if its plugin loads later.
|
||||
if (typeof candidate.type !== 'string') return undefined
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
})
|
||||
}
|
||||
const active = typeof record.active === 'string' ? record.active : null
|
||||
// An active pointer into a dropped diff tab is expected after the drop;
|
||||
// any other missing active is structural corruption → reset the state.
|
||||
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
|
||||
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
|
||||
}
|
||||
if (record.kind === 'split') {
|
||||
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
|
||||
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
|
||||
const children: SplitNode[] = []
|
||||
for (const child of record.children) {
|
||||
const clean = sanitizeNode(child, seen, reid)
|
||||
if (clean === undefined) return undefined
|
||||
children.push(clean)
|
||||
}
|
||||
if (children.length < 2) return undefined
|
||||
if (
|
||||
record.sizes.length !== children.length
|
||||
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The session-scoped store: one state per conversation, localStorage-backed. */
|
||||
export class SidebarStore {
|
||||
private readonly bySession = new Map<string, SidebarState>()
|
||||
private snapshot: SidebarSnapshot = {
|
||||
sessionId: undefined,
|
||||
state: undefined,
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/**
|
||||
* Replace the side card prefs (the settings RPC result / settings page
|
||||
* write). Notifies like any store change: the snapshot carries the prefs,
|
||||
* so consumers that gate on enable switches (the + menu, derived flows)
|
||||
* re-render with the new values immediately.
|
||||
*/
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
this.snapshot = { ...this.snapshot, prefs: this.prefs }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
getPrefs(): SidebarPrefs {
|
||||
return { ...this.prefs }
|
||||
}
|
||||
|
||||
/** Select a session (or none); loads its persisted state. */
|
||||
setSession(sessionId: string | undefined): void {
|
||||
if (this.snapshot.sessionId === sessionId) return
|
||||
if (sessionId === undefined) {
|
||||
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId, this.prefs)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Cache hit: another session's load/ops may have left the uid
|
||||
// counter below THIS session's persisted ids — re-seed so fresh
|
||||
// pane/split ids can never collide with its tree.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
getSnapshot(): SidebarSnapshot {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/** Mutate the current session's state (no-op without a session). */
|
||||
update(mutator: (draft: SidebarState) => void): void {
|
||||
const sessionId = this.snapshot.sessionId
|
||||
const state = this.snapshot.state
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const draft = structuredClone(state)
|
||||
mutator(draft)
|
||||
this.bySession.set(sessionId, draft)
|
||||
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, draft)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tab still exists in its session's state. Views use this on
|
||||
* unmount to tell "the tab was closed" (release the terminal now) from
|
||||
* "the tree re-rendered / the conversation switched" (the tab is still
|
||||
* open — keep the terminal alive through the host's reconnect grace).
|
||||
* Checks the session's own map entry (the current snapshot may already
|
||||
* point at another session when a conversation switch unmounts the old
|
||||
* one's tabs).
|
||||
*/
|
||||
tabOpen(sessionId: string, tabId: string): boolean {
|
||||
const state = this.bySession.get(sessionId)
|
||||
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
|
||||
return state !== undefined && tabOpenIn(state, tabId)
|
||||
}
|
||||
|
||||
/** Apply a pure reducer (returns the next state). */
|
||||
reduce(reducer: (state: SidebarState) => SidebarState): void {
|
||||
const sessionId = this.snapshot.sessionId
|
||||
const state = this.snapshot.state
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||||
window.clearTimeout(this.persistTimer)
|
||||
this.persistTimer = window.setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
|
||||
} catch {
|
||||
// Storage full or unavailable: layout memory is best-effort.
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of [...this.listeners]) listener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one sidebar store instance. Production code calls this only from
|
||||
* the client plugin's `apply` (the instance is handed to components as a
|
||||
* prop); tests call it directly. No module-level singleton: the store's
|
||||
* lifetime belongs to the plugin activation, exactly like the official
|
||||
* `createXXXStore()` factory rule.
|
||||
*/
|
||||
export function createSidebarStore(): SidebarStore {
|
||||
return new SidebarStore()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's history tail
|
||||
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
|
||||
* SubagentLiveLine component turns this into the card's status lines.
|
||||
* Kept framework-free so the parser is unit-testable in the node environment.
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a history tail into the last text output + last tool call (each is
|
||||
* the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing.
|
||||
* @param entries - the tail page from `subagent.history` (oldest → newest).
|
||||
* @returns the last text and/or tool call; an empty object when the tail has neither.
|
||||
*/
|
||||
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
for (const entry of entries) {
|
||||
const { type, data } = entry.event
|
||||
if (type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure subagent-membership helpers over the sessions list feed (structural
|
||||
* mirror world — no runtime imports). Used by the sidebar's auto-activation
|
||||
* effect and the Subagent page:
|
||||
*
|
||||
* - {@link directSubagentCount}: direct durable children of one session,
|
||||
* - {@link detectNewDirectSubagent}: the 0 → N transition that means "a new
|
||||
* subagent just spawned under the current session" (the auto-open trigger),
|
||||
* - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
|
||||
* totals (mirror of the official `indexSubagentDescendants` over the
|
||||
* plugin's own summary rows).
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
} from '../context-types.ts'
|
||||
|
||||
/** Count the direct subagent children of one session (durable `origin` rows). */
|
||||
export function directSubagentCount(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): number {
|
||||
let count = 0
|
||||
for (const summary of Object.values(byId)) {
|
||||
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The main agent of the current session's tree: walk the durable parent
|
||||
* chain upward until the first non-subagent session. The Subagent page shows
|
||||
* THIS root's full topology regardless of how deep the current selection is
|
||||
* (a session whose row is still hydrating, or a broken chain, degrades to
|
||||
* the session itself).
|
||||
*/
|
||||
export function rootAncestor(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
if (sessionId === undefined) return undefined
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = byId[sessionId]
|
||||
while (current !== undefined && current.origin === 'subagent'
|
||||
&& current.parentId !== undefined && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
return current?.id ?? sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every catalog branch (an entry with `hasChildren`) reachable from
|
||||
* the root — the set of catalogs the always-expanded topology consumes.
|
||||
* Cycles fail soft.
|
||||
*/
|
||||
export function collectBranchIds(
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
|
||||
rootId: string | undefined,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (parentId: string): void => {
|
||||
if (seen.has(parentId)) return
|
||||
seen.add(parentId)
|
||||
for (const entry of catalogs[parentId]?.entries ?? []) {
|
||||
if (entry.kind === 'child' && entry.hasChildren) {
|
||||
out.push(entry.id)
|
||||
visit(entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rootId !== undefined) visit(rootId)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new direct subagent appeared under `sessionId` between two
|
||||
* consecutive list snapshots (the count crossed 0 → >0). Switching to a
|
||||
* session that already has subagents yields `false` (its baseline starts at
|
||||
* the current count), so the auto-open never fights an existing layout.
|
||||
*/
|
||||
export function detectNewDirectSubagent(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return directSubagentCount(prev.byId, sessionId) === 0
|
||||
&& directSubagentCount(next.byId, sessionId) > 0
|
||||
}
|
||||
|
||||
/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
|
||||
export interface SubagentDescendantTotals {
|
||||
count: number
|
||||
runningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every subagent descendant under each ancestor it reaches through an
|
||||
* uninterrupted subagent-origin chain (same semantics as the official
|
||||
* `indexSubagentDescendants`; cycles fail soft).
|
||||
*/
|
||||
export function countSubagentDescendants(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): SubagentDescendantTotals {
|
||||
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
|
||||
for (const descendant of Object.values(byId)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.parentId === sessionId) {
|
||||
totals.count += 1
|
||||
if (descendant.running === true) totals.runningCount += 1
|
||||
break
|
||||
}
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Live theme access for surfaces that cannot consume the token colors
|
||||
* directly — xterm's palette and CodeMirror's theme extensions need concrete
|
||||
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
|
||||
* projects prefers-color-scheme and the user's choice onto
|
||||
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
|
||||
* resolved scheme and token values, and notifies subscribers on flips, so
|
||||
* the terminal and the editor re-theme in place instead of freezing in the
|
||||
* scheme they happened to be created under.
|
||||
*/
|
||||
|
||||
/** Whether the app shell resolved to the dark scheme.
|
||||
*
|
||||
* The presenter sets `html { color-scheme }` together with the body palette
|
||||
* attribute, so a set color-scheme means the decision is authoritative (an
|
||||
* absent attribute is then LIGHT even when the OS prefers dark — the user
|
||||
* chose light). Before the presenter has run, fall back to the OS media
|
||||
* query as the best guess.
|
||||
*/
|
||||
export function isDarkScheme(): boolean {
|
||||
if (typeof document === 'undefined') return true
|
||||
const decided = document.documentElement.style.colorScheme !== ''
|
||||
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
/** One token's computed value on <body> ('' while the theme has not applied). */
|
||||
export function tokenValue(name: string): string {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to color-scheme flips (the presenter toggles the body
|
||||
* attribute). The callback fires after the attribute changed; re-read the
|
||||
* scheme inside it.
|
||||
* @returns the disposer.
|
||||
*/
|
||||
export function subscribeColorScheme(callback: () => void): () => void {
|
||||
if (typeof document === 'undefined') return () => {}
|
||||
const observer = new MutationObserver(() => { callback() })
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
||||
return () => { observer.disconnect() }
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
|
||||
* {@link XlsxView} can render it through the Univer sheets preset.
|
||||
*
|
||||
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
|
||||
* - Sheet names + order
|
||||
* - Cell values, typed (string / number / boolean / error → force-string)
|
||||
* - Cell formulas (text form; Univer's formula engine computes them)
|
||||
* - Merged cells (!merges → mergeData)
|
||||
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
|
||||
*
|
||||
* Out of scope for v1 (SheetJS community edition does not parse them and the
|
||||
* Pro edition is commercial): cell styles (font/fill/border/alignment),
|
||||
* conditional formatting, charts. Users who need style fidelity get the
|
||||
* download button on the binary placeholder.
|
||||
*
|
||||
* The function is pure (no DOM, no Univer imports) so it is unit-testable
|
||||
* without jsdom or canvas.
|
||||
*/
|
||||
import type * as XLSX from 'xlsx'
|
||||
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
|
||||
// surface). The enum values BooleanNumber / CellValueType are imported as
|
||||
// runtime values from the same module — the bundler inlines them, and the
|
||||
// test environment has @univerjs/presets available as a dependency.
|
||||
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
|
||||
|
||||
/** SheetJS workbook (the slice of the module we use). */
|
||||
type XLSXWorkbook = XLSX.WorkBook
|
||||
|
||||
/**
|
||||
* Build a Univer workbook snapshot from a SheetJS workbook.
|
||||
*
|
||||
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
|
||||
* @param appVersion - the Univer package version, written into `appVersion`
|
||||
* (Univer requires the field but does not gate on it).
|
||||
* @param locale - the locale Univer should render in.
|
||||
*/
|
||||
export function xlsxWorkbookToUniver(
|
||||
wb: XLSXWorkbook,
|
||||
appVersion: string,
|
||||
locale: LocaleType,
|
||||
): IWorkbookData {
|
||||
const sheetOrder: string[] = []
|
||||
const sheets: Record<string, Partial<IWorksheetData>> = {}
|
||||
|
||||
wb.SheetNames.forEach((name, index) => {
|
||||
const sheetId = `sheet-${index}`
|
||||
sheetOrder.push(sheetId)
|
||||
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
|
||||
})
|
||||
|
||||
// Univer requires at least one sheet; an empty workbook gets a placeholder.
|
||||
if (sheetOrder.length === 0) {
|
||||
const fallbackId = 'sheet-0'
|
||||
sheetOrder.push(fallbackId)
|
||||
sheets[fallbackId] = {
|
||||
id: fallbackId,
|
||||
name: 'Sheet1',
|
||||
rowCount: 20,
|
||||
columnCount: 26,
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
mergeData: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'workbook',
|
||||
name: wb.Props?.Title || 'Workbook',
|
||||
appVersion,
|
||||
locale,
|
||||
styles: {},
|
||||
sheetOrder,
|
||||
sheets,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
|
||||
function worksheetToUniver(
|
||||
ws: XLSX.WorkSheet,
|
||||
name: string,
|
||||
sheetId: string,
|
||||
): Partial<IWorksheetData> {
|
||||
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
|
||||
const merges: IRange[] = []
|
||||
|
||||
// Cell values + formulas: iterate every cell address (skip the !-prefixed
|
||||
// special keys SheetJS uses for sheet-level metadata).
|
||||
for (const addr of Object.keys(ws)) {
|
||||
if (addr.startsWith('!')) continue
|
||||
const cell = ws[addr] as XLSX.CellObject | undefined
|
||||
if (cell === undefined) continue
|
||||
const { r, c } = decodeAddr(addr)
|
||||
const converted = convertCell(cell)
|
||||
if (converted === null) continue
|
||||
let row = cellData[r]
|
||||
if (row === undefined) {
|
||||
row = {}
|
||||
cellData[r] = row
|
||||
}
|
||||
row[c] = converted
|
||||
}
|
||||
|
||||
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
|
||||
// which is exactly Univer's IRange shape — copy verbatim.
|
||||
if (Array.isArray(ws['!merges'])) {
|
||||
for (const m of ws['!merges']!) {
|
||||
if (m === undefined || m === null) continue
|
||||
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
|
||||
}
|
||||
}
|
||||
|
||||
const { rowCount, columnCount } = dimensions(ws, cellData)
|
||||
|
||||
return {
|
||||
id: sheetId,
|
||||
name,
|
||||
tabColor: '',
|
||||
hidden: BooleanNumber.FALSE,
|
||||
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
|
||||
rowCount,
|
||||
columnCount,
|
||||
defaultColumnWidth: 96,
|
||||
defaultRowHeight: 22,
|
||||
mergeData: merges,
|
||||
cellData,
|
||||
rowData: convertRows(ws['!rows']),
|
||||
columnData: convertCols(ws['!cols']),
|
||||
rowHeader: { width: 46 },
|
||||
columnHeader: { height: 20 },
|
||||
showGridlines: BooleanNumber.TRUE,
|
||||
rightToLeft: BooleanNumber.FALSE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
|
||||
function convertCell(cell: XLSX.CellObject): ICellData | null {
|
||||
const out: ICellData = {}
|
||||
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
|
||||
switch (cell.t) {
|
||||
case 'b':
|
||||
out.t = CellValueType.BOOLEAN
|
||||
out.v = cell.v === true
|
||||
break
|
||||
case 'n':
|
||||
out.t = CellValueType.NUMBER
|
||||
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
|
||||
break
|
||||
case 'e':
|
||||
// Excel error strings like #REF! — display verbatim, no formula engine.
|
||||
out.t = CellValueType.FORCE_STRING
|
||||
out.v = String(cell.w ?? cell.v ?? '')
|
||||
break
|
||||
case 's':
|
||||
case 'd':
|
||||
case 'z':
|
||||
default:
|
||||
// String / date / stub. SheetJS renders dates as ISO strings in `w`
|
||||
// when cellDates is on; otherwise `v` is the serial number. Use `w`
|
||||
// (formatted text) when present so users see what Excel showed.
|
||||
out.t = CellValueType.STRING
|
||||
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
|
||||
break
|
||||
}
|
||||
// Formula: keep as text; Univer's formula engine computes on load.
|
||||
if (typeof cell.f === 'string' && cell.f !== '') {
|
||||
out.f = cell.f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
|
||||
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
|
||||
if (!Array.isArray(cols)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
|
||||
cols.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
|
||||
out[index] = {
|
||||
...(w !== undefined ? { w } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
|
||||
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
|
||||
if (!Array.isArray(rows)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
|
||||
rows.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const h = info.hpx ?? info.hpt
|
||||
out[index] = {
|
||||
...(h !== undefined ? { h: Math.round(h) } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
|
||||
* present; otherwise fall back to the max row/col we actually wrote, with a
|
||||
* small floor so the canvas has somewhere to paint.
|
||||
*/
|
||||
function dimensions(
|
||||
ws: XLSX.WorkSheet,
|
||||
cellData: IObjectMatrixPrimitiveType<ICellData>,
|
||||
): { rowCount: number; columnCount: number } {
|
||||
const ref = ws['!ref']
|
||||
if (typeof ref === 'string' && ref !== '') {
|
||||
const range = decodeRange(ref)
|
||||
return {
|
||||
rowCount: Math.max(range.endRow + 1, 20),
|
||||
columnCount: Math.max(range.endColumn + 1, 10),
|
||||
}
|
||||
}
|
||||
let maxRow = -1
|
||||
let maxCol = -1
|
||||
for (const r of Object.keys(cellData)) {
|
||||
const rowNum = Number(r)
|
||||
if (rowNum > maxRow) maxRow = rowNum
|
||||
const rowObj = cellData[rowNum]!
|
||||
for (const c of Object.keys(rowObj)) {
|
||||
const colNum = Number(c)
|
||||
if (colNum > maxCol) maxCol = colNum
|
||||
}
|
||||
}
|
||||
return {
|
||||
rowCount: Math.max(maxRow + 1, 20),
|
||||
columnCount: Math.max(maxCol + 1, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
|
||||
function decodeAddr(addr: string): { r: number; c: number } {
|
||||
let col = 0
|
||||
let i = 0
|
||||
while (i < addr.length) {
|
||||
const ch = addr.charCodeAt(i)
|
||||
if (ch >= 65 && ch <= 90) {
|
||||
col = col * 26 + (ch - 64)
|
||||
i += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const row = Number(addr.slice(i)) - 1
|
||||
return { r: row, c: col - 1 }
|
||||
}
|
||||
|
||||
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
|
||||
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
|
||||
const at = range.indexOf(':')
|
||||
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
|
||||
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
|
||||
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Serializable configuration and defaults for the sidebar host half. Loader
|
||||
* schema validation normally fills defaults; {@link resolveSidebarConfig}
|
||||
* applies the same defaults for direct callers that bypass the Loader.
|
||||
* @module dsh-better-sidebar/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
/** Tunable sidebar host limits (every field optional; defaults fill in). */
|
||||
export interface SidebarConfig {
|
||||
/** Read cap of one text file (bytes); larger files return truncated. */
|
||||
readLimit?: number
|
||||
/** Media route cap (bytes); larger binaries are refused. */
|
||||
mediaLimit?: number
|
||||
/** Explorer row bound of one level. */
|
||||
listLimit?: number
|
||||
/** Terminals per session. */
|
||||
terminalsPerSession?: number
|
||||
/** How long a disconnected terminal process survives awaiting a reconnect. */
|
||||
reconnectGraceMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery schema for the plugin configuration. */
|
||||
export const Config: z<SidebarConfig> = z.object({
|
||||
readLimit: z.number().step(1).min(1).default(512 * 1024),
|
||||
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
|
||||
listLimit: z.number().step(1).min(1).default(1000),
|
||||
terminalsPerSession: z.number().step(1).min(1).default(3),
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
})
|
||||
|
||||
/** Fully defaulted sidebar host settings. */
|
||||
export interface ResolvedSidebarConfig {
|
||||
readLimit: number
|
||||
mediaLimit: number
|
||||
listLimit: number
|
||||
terminalsPerSession: number
|
||||
reconnectGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply direct-call defaults after Loader schema validation has normally run.
|
||||
*
|
||||
* @param config - Deployment-provided sidebar host settings.
|
||||
* @returns Complete settings consumed by the host half.
|
||||
*/
|
||||
export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig {
|
||||
return {
|
||||
readLimit: config?.readLimit ?? 512 * 1024,
|
||||
mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
|
||||
listLimit: config?.listLimit ?? 1000,
|
||||
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
// ── User-facing "Side card" preferences ─────────────────────────────────────
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
})
|
||||
+321
-27
@@ -1,33 +1,327 @@
|
||||
/**
|
||||
* Structural types for the cordis services apes-sidebar consumes.
|
||||
*
|
||||
* PHASE 1: the sidebar tab + session types now come from the REAL
|
||||
* `dsh-better-sidebar` package (type-only imports in `src/client/index.tsx`):
|
||||
* `TabDescriptor` / `TabComponentProps` / `BetterSidebarService` /
|
||||
* `SidebarSessionSummary` / `SidebarSessionList`. The Phase 0 placeholder
|
||||
* mirrors (`ApesSidebarTabDescriptor`, `ApesSidebarService`,
|
||||
* `ApesSessionSummary`, `ApesSessionList`, `ApesSessionsService`, and the
|
||||
* `ApesContext` intersection) were deleted — they are superseded by the
|
||||
* peer's authoritative types and would drift.
|
||||
*
|
||||
* What remains here is the ONE service face apes-sidebar still defines
|
||||
* itself: the locale runtime mirror (`ApesLocaleService`). `dsh-better-sidebar`
|
||||
* publishes its own `SidebarLocaleService`, but locales.ts pre-dates the type
|
||||
* import and stays on this local mirror to keep the i18n module independent
|
||||
* of the peer (it must compile even when the peer is absent).
|
||||
*
|
||||
* This file must stay FREE of Node.js types: it is part of the
|
||||
* CLIENT-reachable declaration graph, so a Node import here would leak into
|
||||
* browser-only consumer builds.
|
||||
* Structural types for the cordis services this plugin consumes, plus the
|
||||
* Context augmentation both halves share. A third-party plugin resolves
|
||||
* outside the DSH monorepo's single cordis instance, so the upstream
|
||||
* `declare module 'cordis'` augmentations do not reach this Context — and
|
||||
* the npm cordis package does not declare the DSH-vendored runtime members
|
||||
* (`ctx.effect`, service properties). The members below mirror the actual
|
||||
* runtime shapes this plugin touches:
|
||||
* - httpServer: @deepseek-ai/dsh-host-webserver
|
||||
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
|
||||
* side the runtime ISessions list feed
|
||||
* - conversation: client side ui-conversation's IConversation (composer
|
||||
* draft), read lazily through `ctx.get` — cross-plugin service reads need
|
||||
* an inject declaration, so the direct property is never typed here
|
||||
* - loader: @cordisjs/plugin-loader (entry options)
|
||||
* - slots: the client runtime SlotsService
|
||||
* - effect: the DSH-vendored cordis lifecycle helper
|
||||
* Drift from upstream is contained to this file.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
import type { BetterSidebarService } from './client/service.ts'
|
||||
|
||||
/** One named webserver route (mirror of the host-webserver WebRoute). */
|
||||
export interface SidebarWebRoute {
|
||||
kind: 'exact' | 'prefix'
|
||||
path: string
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
|
||||
export interface SidebarWebUpgradeRoute {
|
||||
path: string
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** The httpServer service face this plugin uses. */
|
||||
export interface SidebarHttpServer {
|
||||
register(route: SidebarWebRoute): () => void
|
||||
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
|
||||
}
|
||||
|
||||
/** A published session's header slice the sidebar reads (authoritative cwd). */
|
||||
export interface SidebarSessionHeader {
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
|
||||
export interface SidebarSessionStore {
|
||||
get(id: string): { header: SidebarSessionHeader } | undefined
|
||||
}
|
||||
|
||||
/** One loader entry's options slice (the connection row's resolved config). */
|
||||
export interface SidebarLoaderEntry {
|
||||
options: { name: string; config?: unknown }
|
||||
}
|
||||
|
||||
/** The loader face used to read the connection row's trustedHosts config. */
|
||||
export interface SidebarLoader {
|
||||
entries(): Iterable<SidebarLoaderEntry>
|
||||
}
|
||||
|
||||
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
|
||||
export interface SidebarSlotRegisterOptions {
|
||||
name: string
|
||||
key?: string
|
||||
id?: string
|
||||
order?: number
|
||||
label?: string | (() => string)
|
||||
/** Chain routing selector (returns the matched value, or null to pass on). */
|
||||
select?: (owner: unknown) => unknown
|
||||
priority?: number
|
||||
locale?: string
|
||||
registrant?: string
|
||||
/** Business-face factory; args depend on the slot scope. */
|
||||
inject?: (...args: any[]) => Record<string, unknown>
|
||||
children?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The client slots service face (register returns the disposer). */
|
||||
export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* SlotsService.inject): a no-op while the slot is undeclared, so the
|
||||
* settings section registration waits for the settings shell.
|
||||
*/
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
}
|
||||
|
||||
/** The client session list row the sidebar reads (cwd for the explorer). */
|
||||
export interface SidebarSessionSummary {
|
||||
id: string
|
||||
cwd?: string
|
||||
displayTitle: string
|
||||
/** Coarse durable origin for navigation filtering (subagent children). */
|
||||
origin?: 'subagent'
|
||||
/** Durable direct parent session id (present on subagent children). */
|
||||
parentId?: string
|
||||
/** Whether the session's agent is currently running. */
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
/** One healthy subagent catalog child row (structural mirror of the runtime). */
|
||||
export interface SidebarSubagentChildEntry {
|
||||
kind: 'child'
|
||||
id: string
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
mode: 'one-shot' | 'continuable'
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** One unreadable catalog row (corrupt / unsupported / unavailable). */
|
||||
export interface SidebarSubagentDiagnosticEntry {
|
||||
kind: 'diagnostic'
|
||||
id: string
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** The per-parent lazy catalog delivered through the sessions list feed. */
|
||||
export interface SidebarSubagentCatalog {
|
||||
entries: Array<SidebarSubagentChildEntry | SidebarSubagentDiagnosticEntry>
|
||||
parentAvailable: boolean
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: { code?: string; message?: string } | null
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export interface SidebarSubagentAddress {
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: 'one-shot' | 'continuable'
|
||||
}
|
||||
|
||||
/** Minimal structural mirror of one session event (the subagent history tail). */
|
||||
export interface SidebarSessionEvent {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** One history row: the durable event plus an optional tool presentation view. */
|
||||
export interface SidebarHistoryEntry {
|
||||
event: SidebarSessionEvent
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** Unary response mirror (`RpcResponse<T>` on the wire). */
|
||||
export interface SidebarRpcResponse<T> {
|
||||
rpcId: unknown
|
||||
result: SidebarRpcResult<T>
|
||||
}
|
||||
|
||||
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
|
||||
export interface SidebarConnectionHandle {
|
||||
api: {
|
||||
subagents: {
|
||||
history(
|
||||
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client session list snapshot the sidebar subscribes to. */
|
||||
export interface SidebarSessionList {
|
||||
current: string | undefined
|
||||
byId: Record<string, SidebarSessionSummary>
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
export interface SidebarSessionsService {
|
||||
list: {
|
||||
getSnapshot(): SidebarSessionList
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
/**
|
||||
* Select a listed session as current (mirror of the runtime ISessions.open)
|
||||
* — used to jump back to the main agent from the topology root node.
|
||||
*/
|
||||
open?(id: string): void
|
||||
/**
|
||||
* Resolve an Agent-scoped context view for one session (mirror of the
|
||||
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
|
||||
* requires to reach that session's composer.
|
||||
*/
|
||||
scope(id: string): Context | undefined
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address
|
||||
* (mirror of the runtime ISessions.openSubagent).
|
||||
*/
|
||||
openSubagent?(address: SidebarSubagentAddress): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
*/
|
||||
subagentAddress?(id: string): SidebarSubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog surface is consuming live membership updates.
|
||||
*/
|
||||
setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
*/
|
||||
refreshSubagents?(parentSessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||
export interface SidebarSessionInput {
|
||||
/** The live input store (draft read for append). */
|
||||
state: {
|
||||
getSnapshot(): { draft: string }
|
||||
}
|
||||
/** Replace the draft text (the input machine's single public write path). */
|
||||
setDraft(text: string): void
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.get('conversation')`. */
|
||||
export interface SidebarConversation {
|
||||
input: {
|
||||
for(actx: Context): SidebarSessionInput
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client locale service face (mirror of dsh-client-locale's LocaleRuntime).
|
||||
* Only the slices apes-sidebar's i18n module touches: snapshot read, snapshot
|
||||
* subscription, and per-namespace dictionary registration.
|
||||
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
|
||||
* InvariantService). The upstream augmentation does not reach this Context
|
||||
* (dual-cordis-instance resolution), so the register signature is restated
|
||||
* structurally, exactly like the other service faces above.
|
||||
*/
|
||||
export interface ApesLocaleService {
|
||||
getSnapshot(): { active: string }
|
||||
subscribe(fn: () => void): () => void
|
||||
register(ns: string, locale: string, dict: Record<string, string>): () => void
|
||||
export interface SidebarInvariantsService {
|
||||
/** Reserve one package's checks and install them in the service's child fiber. */
|
||||
register(
|
||||
packageName: string,
|
||||
installer: (ctx: Context, fail: (message: string) => never) => void | Promise<void>,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
* then the composition base, then the user document).
|
||||
*/
|
||||
register<T>(
|
||||
ns: string,
|
||||
schema: unknown,
|
||||
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
|
||||
): {
|
||||
get(): T
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
update(patch: object): Promise<void>
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
/** Redacted descriptors of every registered namespace (secrets stripped). */
|
||||
describe(options?: { redactSecrets?: boolean }): Array<{
|
||||
ns: string
|
||||
value?: unknown
|
||||
base?: unknown
|
||||
user?: unknown
|
||||
applies: 'live' | 'restart'
|
||||
revision: number
|
||||
}>
|
||||
/** Service-level merge write with the revision guard (a stale writer is refused). */
|
||||
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
|
||||
* The host half registers model-facing tools here; the registry attaches the
|
||||
* returned disposer to the contributing fiber so unloading unregisters them.
|
||||
*/
|
||||
export interface SidebarToolsService {
|
||||
/** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */
|
||||
register(tool: unknown): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's
|
||||
* Agent). Only the slices the terminal tools touch are restated: the live
|
||||
* session identity and its header cwd, both readonly.
|
||||
*/
|
||||
export interface SidebarAgent {
|
||||
/** The live session identity shared with the session log. */
|
||||
readonly id: string
|
||||
/** The live session this agent drives. */
|
||||
readonly session: {
|
||||
/** The session's header (validated cwd, lineage metadata). */
|
||||
readonly header: { readonly cwd?: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
tools: SidebarToolsService
|
||||
/**
|
||||
* The client-side sidebar registry: external plugins register tab types
|
||||
* and file previewers here. Provided by the client half (see
|
||||
* {@link ./client/index.tsx}); undefined on the host side.
|
||||
*/
|
||||
betterSidebar: BetterSidebarService
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
* activation; its returned cleanup runs at disposal.
|
||||
*/
|
||||
effect(fn: () => void | (() => void), label?: string): void
|
||||
}
|
||||
}
|
||||
|
||||
export type { Context }
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Single-level directory listing for the sidebar explorer. Streams the level
|
||||
* with opendir, sorts directories first then names (case-insensitive), and
|
||||
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
||||
* reported as files without probing their target — the explorer shows what
|
||||
* dirent says, keeping the read cheap for arbitrarily large levels.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** One explorer row. */
|
||||
export interface SidebarFsEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** One listed level. */
|
||||
export interface SidebarFsListing {
|
||||
path: string
|
||||
entries: SidebarFsEntry[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
|
||||
export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
/**
|
||||
* List one directory level.
|
||||
* @param path - absolute directory path.
|
||||
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
|
||||
* @returns the sorted listing.
|
||||
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
|
||||
*/
|
||||
export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
|
||||
let level
|
||||
try {
|
||||
level = await opendir(path)
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
||||
}
|
||||
const rows: SidebarFsEntry[] = []
|
||||
let overflow = 0
|
||||
try {
|
||||
for await (const dirent of level) {
|
||||
if (rows.length >= maxEntries) {
|
||||
overflow += 1
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
name: dirent.name,
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
path: join(path, dirent.name),
|
||||
isDir: dirent.isDirectory(),
|
||||
hidden: dirent.name.startsWith('.'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
||||
}
|
||||
rows.sort(compareEntries)
|
||||
return { path, entries: rows, truncated: overflow > 0 }
|
||||
}
|
||||
|
||||
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
||||
export function rootLabel(path: string): string {
|
||||
const base = basename(path)
|
||||
return base !== '' ? base : path
|
||||
}
|
||||
|
||||
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
|
||||
export function parentOf(path: string): string | undefined {
|
||||
const parent = dirname(path)
|
||||
return parent === path ? undefined : parent
|
||||
}
|
||||
|
||||
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
||||
export function requireAbsolute(path: string): string {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
||||
* style and — on Windows, where the filesystem is case-insensitive — of
|
||||
* letter case. The media route uses this instead of a raw `startsWith` so a
|
||||
* case-mismatched or mixed-separator path can never be misclassified
|
||||
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
|
||||
* @param platform - filesystem semantics; injectable so both branches are
|
||||
* unit-testable on any host.
|
||||
*/
|
||||
export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
|
||||
const b = norm(base)
|
||||
const t = norm(target)
|
||||
if (platform === 'win32') {
|
||||
const lb = b.toLowerCase()
|
||||
const lt = t.toLowerCase()
|
||||
return lt === lb || lt.startsWith(`${lb}/`)
|
||||
}
|
||||
return t === b || t.startsWith(`${b}/`)
|
||||
}
|
||||
|
||||
/** Message text of an unknown thrown value. */
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Git operations for the sidebar source-control panel. Everything goes
|
||||
* through the system `git` binary spawned per request (no library, no state),
|
||||
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
||||
* so parsing never depends on locale or color config. All commands run with
|
||||
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
||||
* `-c color.ui=false` so output stays machine-readable.
|
||||
*
|
||||
* Commits use the user's git global identity untouched (never sets
|
||||
* user.name/user.email).
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
/** A parsed `git status --porcelain=v1 -z` entry. */
|
||||
export interface GitStatusEntry {
|
||||
path: string
|
||||
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
|
||||
xy: string
|
||||
}
|
||||
|
||||
/** The source-control panel snapshot. */
|
||||
export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
}
|
||||
|
||||
/** One `git log` row. */
|
||||
export interface GitLogEntry {
|
||||
/** Short hash (7+ chars, display). */
|
||||
hash: string
|
||||
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
|
||||
hashFull: string
|
||||
subject: string
|
||||
author: string
|
||||
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
|
||||
date: string
|
||||
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
||||
refs: string
|
||||
}
|
||||
|
||||
/** One git failure (stderr text as the message). */
|
||||
export class GitCommandError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code = 'git-error',
|
||||
readonly command: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
||||
export function parsePorcelainZ(output: string): GitStatusEntry[] {
|
||||
const tokens = output.split('\0')
|
||||
const entries: GitStatusEntry[] = []
|
||||
let index = 0
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index]!
|
||||
index += 1
|
||||
if (token === '') continue
|
||||
const xy = token.slice(0, 2)
|
||||
const rest = token.slice(3)
|
||||
entries.push({ path: rest, xy })
|
||||
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
|
||||
// new path (the file as it exists now) is the display path.
|
||||
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
||||
export function parseLogLines(output: string): GitLogEntry[] {
|
||||
const rows: GitLogEntry[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
if (line === '') continue
|
||||
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
|
||||
if (hash === undefined || subject === undefined) continue
|
||||
rows.push({
|
||||
hash,
|
||||
subject,
|
||||
author: author ?? '',
|
||||
date: date ?? '',
|
||||
hashFull: hashFull ?? hash,
|
||||
refs: refs ?? '',
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
||||
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn('git', full, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout)
|
||||
} else {
|
||||
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
|
||||
return out.trim() === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
||||
export async function repoRoot(cwd: string): Promise<string> {
|
||||
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
||||
export async function currentBranch(cwd: string): Promise<string> {
|
||||
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** Working-tree status (untracked included). */
|
||||
export async function status(cwd: string): Promise<GitStatusResult> {
|
||||
const repo = await isGitRepo(cwd)
|
||||
if (!repo) return { isRepo: false, entries: [] }
|
||||
const [branch, raw] = await Promise.all([
|
||||
currentBranch(cwd).catch(() => 'HEAD'),
|
||||
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
|
||||
])
|
||||
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
|
||||
}
|
||||
|
||||
/** Diff text of the worktree (unstaged) or the index (staged). */
|
||||
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
|
||||
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
|
||||
if (staged) args.push('--cached')
|
||||
if (path !== undefined) args.push('--', path)
|
||||
return runGit(cwd, args)
|
||||
}
|
||||
|
||||
/** Stage paths (all when path is undefined). */
|
||||
export async function stage(cwd: string, path: string | undefined): Promise<void> {
|
||||
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
|
||||
}
|
||||
|
||||
/** Unstage paths (all when path is undefined). */
|
||||
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
|
||||
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
|
||||
}
|
||||
|
||||
/** Commit the staged changes with a message (global identity untouched). */
|
||||
export async function commit(cwd: string, message: string): Promise<void> {
|
||||
await runGit(cwd, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
/** Branch names (current first). */
|
||||
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
|
||||
const [current, raw] = await Promise.all([
|
||||
currentBranch(cwd).catch(() => 'HEAD'),
|
||||
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
||||
])
|
||||
const names = raw.split('\n').filter(line => line !== '')
|
||||
return { current, names: names.includes(current) ? names : [current, ...names] }
|
||||
}
|
||||
|
||||
/** Switch to an existing branch. */
|
||||
export async function checkout(cwd: string, branch: string): Promise<void> {
|
||||
await runGit(cwd, ['checkout', branch])
|
||||
}
|
||||
|
||||
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
||||
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
|
||||
const raw = await runGit(cwd, [
|
||||
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
|
||||
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
|
||||
])
|
||||
return parseLogLines(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
||||
* revision has no such path (a new/untracked file has no HEAD side).
|
||||
*/
|
||||
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
|
||||
try {
|
||||
return await runGit(cwd, ['show', `${rev}:${path}`])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
||||
* Merge commits show their diff against the first parent (`-m --first-parent`
|
||||
* is a no-op for regular commits), so a history click always has content. */
|
||||
export async function commitDiff(cwd: string, hash: string): Promise<string> {
|
||||
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
|
||||
}
|
||||
|
||||
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
||||
export async function discard(cwd: string, path: string): Promise<void> {
|
||||
await runGit(cwd, ['checkout', '--', path])
|
||||
}
|
||||
|
||||
/** Revert one commit onto the current branch with an auto-generated message. */
|
||||
export async function revert(cwd: string, hash: string): Promise<void> {
|
||||
await runGit(cwd, ['revert', '--no-edit', hash])
|
||||
}
|
||||
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
export async function cherryPick(cwd: string, hash: string): Promise<void> {
|
||||
await runGit(cwd, ['cherry-pick', hash])
|
||||
}
|
||||
+748
-51
@@ -1,60 +1,757 @@
|
||||
/**
|
||||
* apes-sidebar host-half entry.
|
||||
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
||||
* read/write, git), the /sidebar/file media route (images), and the terminal
|
||||
* WebSocket upgrade. Every route passes the same browser-trust fence as the
|
||||
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
|
||||
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
|
||||
* trustedHosts read live from the connection loader row so the fence never
|
||||
* drifts from the deployment's.
|
||||
*
|
||||
* The host half is intentionally MINIMAL for Phase 0: it mounts a logger
|
||||
* line so the cordis patch's `insert` row is exercised end-to-end (the
|
||||
* scratch-profile mount smoke asserts this package's fiber activates).
|
||||
*
|
||||
* Phase 2 (Tutti capability layer) will add the host routes the tabs need:
|
||||
* - subagent topology / job output replay (mirror of better-sidebar's
|
||||
* subagent-live-route.ts + jobs-routes.ts)
|
||||
* - agent-programmable open-tab registry (mirror of better-sidebar's
|
||||
* agent-opens.ts)
|
||||
* All host routes follow the better-sidebar trust-fence + session-isolation
|
||||
* model (same-origin loopback fence, per-request cwd from the session store).
|
||||
*
|
||||
* The client half (the actual sidebar tabs) lives in src/client/index.tsx and
|
||||
* is loaded through the package.json `dsh.client.inject` declaration; it
|
||||
* registers tabs into `ctx.betterSidebar` (better-sidebar's registry).
|
||||
*
|
||||
* inject list: Phase 0 needs only `logger` (always present). Phase 2 will
|
||||
* add `webServer` + `sessions` when the host routes land.
|
||||
* All operations are conversation-scoped: requests carry a sessionId, the
|
||||
* session's authoritative cwd comes from the session store, and terminal
|
||||
* processes are keyed by session.
|
||||
*/
|
||||
export const name = 'apes-sidebar'
|
||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_NS,
|
||||
type ResolvedSidebarConfig,
|
||||
type SidebarConfig,
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
/**
|
||||
* The cordis services the host half injects.
|
||||
*
|
||||
* MUST stay empty: the host half uses `ctx.logger` only via optional-chaining
|
||||
* (`log?.info?.()` inside try/catch) — it is a soft convenience, not a hard
|
||||
* dependency. Declaring it in `inject` would make the entry wait on a
|
||||
* `logger` service that no DSH host provider registers, leaving the entry
|
||||
* `pending` forever and failing the plugin tree load (recovery mode).
|
||||
* Compare dsh-better-sidebar, whose inject list is services the host
|
||||
* actually provides (`webServer`/`sessions`/`webRuntime`/`tools`).
|
||||
*/
|
||||
export const inject = [] as const
|
||||
export { Config }
|
||||
export type { SidebarConfig, ResolvedSidebarConfig }
|
||||
// Re-export the Context augmentation (declare module 'cordis') so consumers
|
||||
// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
|
||||
// Also re-export the service descriptor types so consumers can type their
|
||||
// registerTab / registerFileViewer arguments without reaching into /client.
|
||||
export type { Context } from './context-types.ts'
|
||||
export type {
|
||||
BetterSidebarService,
|
||||
TabDescriptor,
|
||||
TabComponentProps,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
FileFetchStrategy,
|
||||
} from './client/service.ts'
|
||||
|
||||
/**
|
||||
* The cordis effect entry: runs once per activation on the Node half.
|
||||
* Phase 0 — log a mount marker and validate the better-sidebar peer
|
||||
* version (warn-only, never blocks mount).
|
||||
*/
|
||||
export function apply(ctx: { logger?: { info: (msg: string) => void }; effect?: (fn: () => void | (() => void)) => () => void }): void {
|
||||
const log = ctx.logger
|
||||
const mount = () => {
|
||||
try {
|
||||
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
|
||||
} catch {
|
||||
// Never let a logging failure abort the fiber.
|
||||
/** Plugin identity for cordis.yml rows. */
|
||||
export const name = 'dsh-better-sidebar'
|
||||
|
||||
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
|
||||
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
|
||||
|
||||
/** Content types for the media route, by extension. */
|
||||
const MEDIA_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.pdf': 'application/pdf',
|
||||
}
|
||||
|
||||
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
||||
export function mediaTypeForPath(path: string): string {
|
||||
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
||||
function trustedHostsOf(ctx: Context): string[] {
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.options.name === 'connection') {
|
||||
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
||||
return config?.trustedHosts ?? []
|
||||
}
|
||||
}
|
||||
if (typeof ctx.effect === 'function') {
|
||||
ctx.effect(() => {
|
||||
mount()
|
||||
return () => undefined
|
||||
})
|
||||
} else {
|
||||
mount()
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's authoritative working directory. The attached session
|
||||
* header wins; while the session is still hydrating from persistence (the
|
||||
* web client attaches the current conversation a moment after page load, so
|
||||
* the very first sidebar requests can arrive detached) the caller's own
|
||||
* list-summary cwd is used; the process cwd is the last resort (blank
|
||||
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
||||
* explorer/git/terminal work from first paint instead of surfacing
|
||||
* "session ... has no working directory".
|
||||
*/
|
||||
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
const headerCwd = session?.header.cwd
|
||||
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
|
||||
if (clientCwd !== undefined && clientCwd !== '') {
|
||||
try {
|
||||
return requireAbsolute(clientCwd)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
||||
}
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path that a git command reported — `git status`/`git diff`
|
||||
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
|
||||
* session cwd (a session inside a subdirectory of a repository). Absolute
|
||||
* paths pass through; relative ones join the repo root (falling back to the
|
||||
* cwd when the root cannot be resolved, e.g. a bare directory).
|
||||
*/
|
||||
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
|
||||
if (isAbsolute(raw)) return requireAbsolute(raw)
|
||||
const root = await git.repoRoot(cwd).catch(() => cwd)
|
||||
return requireAbsolute(join(root, raw))
|
||||
}
|
||||
|
||||
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
||||
const READ_HEAD_LIMIT = 4096
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe.
|
||||
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
||||
* so the client can re-match viewers by content (`detect`). */
|
||||
async function readText(path: string, readLimit: number): Promise<{
|
||||
content: string
|
||||
truncated: boolean
|
||||
binary: boolean
|
||||
size: number
|
||||
head?: string
|
||||
}> {
|
||||
const info = await stat(path).catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
if (info.isDirectory()) {
|
||||
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
||||
}
|
||||
const size = info.size
|
||||
const truncated = size > readLimit
|
||||
const handle = await open(path, 'r').catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(size, readLimit))
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
||||
const slice = buffer.subarray(0, bytesRead)
|
||||
const binary = slice.includes(0)
|
||||
const head = binary
|
||||
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
|
||||
: undefined
|
||||
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** One API method dispatch table entry. */
|
||||
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
|
||||
/**
|
||||
* The live face of the side card settings namespace, bound to the settings
|
||||
* service when it is mounted. The DSH settings RPC domain only serves
|
||||
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
||||
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
||||
* which call the seam in-process — no configuration-client gate involved.
|
||||
*/
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
get(): { value?: unknown; revision?: number }
|
||||
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
|
||||
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const record = payload as { cwd?: unknown } | null
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
|
||||
},
|
||||
'fs.tree': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
||||
return listDirectory(target, resolved.listLimit)
|
||||
},
|
||||
'fs.read': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
// names; the untracked diff view reads the file through this route).
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated, head }
|
||||
return { kind: 'text', content, truncated }
|
||||
},
|
||||
'fs.write': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const path = requireAbsolute(requireString(payload, 'path'))
|
||||
const content = requireString(payload, 'content')
|
||||
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(tmp, content, 'utf8')
|
||||
await rename(tmp, path)
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true }).catch(() => {})
|
||||
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
'git.status': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return git.status(cwd)
|
||||
},
|
||||
'git.diff': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown; staged?: unknown }
|
||||
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
return { diff: await git.diff(cwd, path, record.staged === true) }
|
||||
},
|
||||
'git.stage': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
||||
await git.stage(cwd, path)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.unstage': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
||||
await git.unstage(cwd, path)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.commit': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const message = requireString(payload, 'message')
|
||||
await git.commit(cwd, message)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.branch': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return git.branches(cwd)
|
||||
},
|
||||
'git.checkout': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.checkout(cwd, requireString(payload, 'branch'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.log': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { count?: unknown; skip?: unknown }
|
||||
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
|
||||
? record.count
|
||||
: undefined
|
||||
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
|
||||
? record.skip
|
||||
: undefined
|
||||
return git.log(cwd, count, skip)
|
||||
},
|
||||
'git.commit-diff': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
|
||||
},
|
||||
'git.discard': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.revert': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.revert(cwd, requireString(payload, 'hash'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.cherry-pick': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.cherryPick(cwd, requireString(payload, 'hash'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.show': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const rev = requireString(payload, 'rev')
|
||||
return { content: await git.show(cwd, rev, path) }
|
||||
},
|
||||
// Release a terminal immediately. The WebSocket close frame already does
|
||||
// this while the socket is open; this route covers the tab-close that
|
||||
// happens while the socket is down (reconnect loop), so a closed tab can
|
||||
// never hold the per-session quota until the reconnect grace expires.
|
||||
'pty.close': (payload) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const tab = requireString(payload, 'tab')
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// Release an agent terminal by uuid. The WS close frame already does
|
||||
// this while the socket is open; this route covers the tab-close that
|
||||
// happens while the socket is down (reconnect loop) so a closed agent
|
||||
// tab never leaves a zombie pty behind. Idempotent.
|
||||
'agent-pty.close': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
// is refused with settings-conflict so a concurrent change is never
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
const settings = getSettings()
|
||||
return settings?.get() ?? { value: undefined, revision: undefined }
|
||||
},
|
||||
'settings.update': async (payload) => {
|
||||
const settings = getSettings()
|
||||
if (settings === undefined) {
|
||||
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
||||
}
|
||||
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
||||
const patch = record?.patch
|
||||
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new SidebarError('bad-request', 'patch must be a plain object')
|
||||
}
|
||||
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
||||
try {
|
||||
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
||||
} catch (error) {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
throw new SidebarError('settings-conflict', error.message, 409)
|
||||
}
|
||||
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin body: mount the fenced routes and the pty lifecycle.
|
||||
* @param ctx - host plugin context (httpServer, sessions, loader).
|
||||
* @param config - deployment-provided limits; the Loader validates against
|
||||
* {@link Config} and fills defaults, direct callers get them from
|
||||
* {@link resolveSidebarConfig}.
|
||||
*/
|
||||
export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
||||
// restore it before any terminal can spawn (idempotent).
|
||||
ensureSpawnHelper()
|
||||
const resolved = resolveSidebarConfig(config)
|
||||
const trustedHosts = trustedHostsOf(ctx)
|
||||
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
||||
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
||||
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
||||
// uncapped, and torn down with the plugin. The model creates terminals here
|
||||
// through the terminal_create tool; the sidebar view attaches through the
|
||||
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
||||
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
// (client half) can render and persist the new-conversation defaults. The
|
||||
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
||||
// configuration clients, so the client reaches this namespace through the
|
||||
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
// The model-facing terminal tools are gated on the side-card setting
|
||||
// `agentTerminalTools` (default off): nothing is injected until the user
|
||||
// turns the feature on, and turning it off mid-session unregisters the
|
||||
// tools and releases the agent terminals they created.
|
||||
let toolsDisposers: (() => void) | null = null
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
toolsDisposers()
|
||||
toolsDisposers = null
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
||||
get(): SidebarPrefs
|
||||
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
||||
}
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
// Register (or unregister) the terminal tools from the current setting,
|
||||
// and keep them in sync with every settings commit.
|
||||
syncToolsGate(scope)
|
||||
scope.watch(() => { syncToolsGate(scope) })
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
||||
return
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
|
||||
if (method === undefined || method.includes('/')) {
|
||||
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = await readJsonBody(req)
|
||||
const handler = api[method]
|
||||
if (handler === undefined) {
|
||||
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
|
||||
}
|
||||
writeOk(res, await handler(payload))
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/api routes')
|
||||
|
||||
// ── Media route (images for the editor) ─────────────────────────────────
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/file',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const raw = url.searchParams.get('path')
|
||||
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const path = requireAbsolute(raw)
|
||||
if (!isWithin(cwd, path)) {
|
||||
// Only files under the session cwd are served as media (the editor
|
||||
// opens images from the explorer; produced files go through read).
|
||||
// isWithin (not a raw startsWith) so case-mismatched Windows paths
|
||||
// and mixed separators cannot be misclassified.
|
||||
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
|
||||
}
|
||||
const info = await stat(path)
|
||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||
}
|
||||
const type = mediaTypeForPath(path)
|
||||
const body = await readFile(path)
|
||||
// Raw bytes either way (binary-safe); ?download=1 switches the
|
||||
// disposition so the browser saves the file instead of showing it.
|
||||
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
|
||||
if (url.searchParams.get('download') === '1') {
|
||||
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
|
||||
}
|
||||
res.writeHead(200, headers)
|
||||
res.end(body)
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/file media route')
|
||||
|
||||
// ── Terminal WebSocket ──────────────────────────────────────────────────
|
||||
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
|
||||
// agent-owned terminals (?uuid=...). The two paths attach to different
|
||||
// registries but share the wire protocol: input frames are raw text,
|
||||
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
|
||||
// `{type:'close'}` releases the underlying pty (immediate for agent
|
||||
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
|
||||
// contract the host has always had).
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path: '/sidebar/ws/terminal',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: terminal WebSocket')
|
||||
|
||||
// ── Agent terminals push WebSocket ──────────────────────────────────────
|
||||
// Pushes the live list of agent terminals for one session to the sidebar
|
||||
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
|
||||
// title from the agent's `terminal_create` call). The host fires on every
|
||||
// create / close / exit; the client reconciles by adding tabs for new
|
||||
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
|
||||
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
|
||||
// fires a change here, which converges the view).
|
||||
const agentListWss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path: '/sidebar/ws/agent-terminals',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
agentListWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
void attachAgentList(agentPtyRegistry, ws, req)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
wss.close()
|
||||
agentListWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
}
|
||||
|
||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
if (sessionId === null) {
|
||||
ws.close(1008, 'sessionId is required')
|
||||
return
|
||||
}
|
||||
const send = (): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(registry.list(sessionId)))
|
||||
}
|
||||
}
|
||||
send()
|
||||
const unsubscribe = registry.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe() })
|
||||
ws.on('error', () => { unsubscribe() })
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
||||
* Two attach modes share the wire protocol:
|
||||
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
||||
* `terminal_create` tool). The close frame kills the pty immediately
|
||||
* (the agent's terminal closes when the user closes the sidebar tab); a
|
||||
* bare socket drop (refresh, tab switch) leaves the pty alive for the
|
||||
* reconnect grace, exactly like UI-tab terminals.
|
||||
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
||||
* created it from the + menu). The close frame schedules a 0-ms close
|
||||
* (the host's reconnect grace keeps the shell alive across a refresh).
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
if (uuid !== null) {
|
||||
const handle = agentPtyRegistry.get(uuid)
|
||||
if (handle === undefined) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
return
|
||||
}
|
||||
pumpAgentTerminal(agentPtyRegistry, handle, ws)
|
||||
return
|
||||
}
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const tabId = url.searchParams.get('tab')
|
||||
if (sessionId === null || tabId === null) {
|
||||
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
||||
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
||||
}
|
||||
const dataSub = handle.pty.onData(onData)
|
||||
const exitSub = handle.pty.onExit(onExit)
|
||||
ws.on('message', (data) => {
|
||||
const text = data.toString('utf8')
|
||||
// Control frames are JSON with a known shape; anything else (including
|
||||
// JSON that is not a recognized control) is terminal input, verbatim.
|
||||
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: terminal input.
|
||||
}
|
||||
if (control !== null && control.type === 'close') {
|
||||
// The owning tab was closed: release the quota immediately.
|
||||
ptyManager.scheduleClose(handle.key, 0)
|
||||
return
|
||||
}
|
||||
if (handle.exited) return
|
||||
if (
|
||||
control !== null
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else {
|
||||
handle.pty.write(text)
|
||||
}
|
||||
})
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the process alive
|
||||
// for a grace period so a quick reconnect keeps it; the reconnect's
|
||||
// open() cancels the pending close.
|
||||
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
||||
})
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump one agent terminal's pty to a connected view. The close frame kills
|
||||
* the pty immediately (the agent's terminal closes when the user closes the
|
||||
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
|
||||
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
|
||||
* plugin teardown kills it.
|
||||
*/
|
||||
function pumpAgentTerminal(
|
||||
registry: AgentPtyRegistry,
|
||||
handle: AgentTerminalHandle,
|
||||
ws: WebSocket,
|
||||
): void {
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
||||
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
||||
}
|
||||
const dataSub = handle.pty.onData(onData)
|
||||
const exitSub = handle.pty.onExit(onExit)
|
||||
ws.on('message', (data) => {
|
||||
if (handle.exited) return
|
||||
const text = data.toString('utf8')
|
||||
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: terminal input.
|
||||
}
|
||||
if (control !== null && control.type === 'close') {
|
||||
// The user closed the sidebar tab: kill the pty immediately. The
|
||||
// agent's next terminal_list / terminal_send will see it gone.
|
||||
registry.close(handle.uuid)
|
||||
return
|
||||
}
|
||||
if (
|
||||
control !== null
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else if (control === null) {
|
||||
// Raw text input (a JSON-looking string the pty would have received
|
||||
// verbatim is reachable in theory but is exotic for an agent terminal;
|
||||
// preserve the UI-tab semantics and forward as input).
|
||||
handle.pty.write(text)
|
||||
}
|
||||
// An unrecognized JSON control frame is dropped (the UI-tab path also
|
||||
// treats non-resize JSON controls as input, but for an agent terminal
|
||||
// there is no realistic input that is also valid JSON).
|
||||
})
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
|
||||
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
|
||||
// frame, or plugin teardown kills it. A reconnecting view reattaches the
|
||||
// same shell and gets the full transcript replayed.
|
||||
})
|
||||
}
|
||||
|
||||
+22
-79
@@ -1,89 +1,32 @@
|
||||
/**
|
||||
* The apes-sidebar invariant surface: a minimal self-check companion module
|
||||
* (mirror of better-sidebar's `lib/invariant.js`). Shipped as a separate
|
||||
* entry so consumers can `import { assert } from 'apes-sidebar/invariant'`
|
||||
* without pulling the host-half apply() graph.
|
||||
*
|
||||
* Kept dependency-free: this module must never import Node builtins or
|
||||
* other @deepseek-ai packages, because it sits in the client-reachable
|
||||
* declaration graph.
|
||||
* Package-owned invariant companion for `dsh-better-sidebar`.
|
||||
* @module dsh-better-sidebar/invariant
|
||||
*/
|
||||
|
||||
/** Thrown by {@link assert} when the condition is falsy. */
|
||||
export class InvariantError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvariantError'
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from './context-types.ts'
|
||||
|
||||
/** Assert a condition; throw an {@link InvariantError} with `message` when falsy. */
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new InvariantError(message)
|
||||
}
|
||||
const PACKAGE_NAME = 'dsh-better-sidebar'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'dsh-better-sidebar-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* The invariant installer signature: register one package's checks in the
|
||||
* service's child fiber. `fail` throws (never returns) to abort the mount.
|
||||
* No runtime invariant: the sidebar owns no service state or event protocol
|
||||
* of its own — every route is mounted under the host's httpServer fence, the
|
||||
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
|
||||
* plain snapshot registry asserted by the behavior specs. The route fence,
|
||||
* pty quota, and store semantics are each observed through their seams.
|
||||
*/
|
||||
export type InvariantInstaller = (
|
||||
ctx: unknown,
|
||||
fail: (message: string) => never,
|
||||
) => void | Promise<void>
|
||||
const install: () => void = () => {}
|
||||
|
||||
/**
|
||||
* Guard the better-sidebar peer dependency contract at host startup:
|
||||
* apes-sidebar requires `dsh-better-sidebar` >= 0.18.0. The check is a
|
||||
* no-op in the client bundle (it only runs on the Node half) and a warning
|
||||
* (not a hard fail) so a fresh DSH without the peer still mounts — the
|
||||
* tabs simply won't appear until the peer is present.
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export function assertBetterSidebarPeer(
|
||||
logger: { warn: (msg: string) => void } | undefined,
|
||||
): void {
|
||||
let pkg: { version?: string } | undefined
|
||||
try {
|
||||
// Dynamic import so the client bundle never statically pulls a Node
|
||||
// resolution path; the import is guarded and never awaited here.
|
||||
pkg = (globalThis as { __apesPeer__?: unknown }).__apesPeer__ as { version?: string } | undefined
|
||||
} catch {
|
||||
pkg = undefined
|
||||
}
|
||||
const version = pkg?.version
|
||||
if (version && compareSemver(version, '0.18.0') < 0) {
|
||||
logger?.warn(
|
||||
`[apes-sidebar] dsh-better-sidebar ${version} is older than the required 0.18.0 — `
|
||||
+ 'sidebar tabs will not register. Upgrade dsh-better-sidebar.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare two semver strings (numeric, prerelease-aware enough for rc tags). */
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const pa = parseSemver(a)
|
||||
const pb = parseSemver(b)
|
||||
if (pa.num[0] !== pb.num[0]) return pa.num[0] - pb.num[0]
|
||||
if (pa.num[1] !== pb.num[1]) return pa.num[1] - pb.num[1]
|
||||
if (pa.num[2] !== pb.num[2]) return pa.num[2] - pb.num[2]
|
||||
// Both release, or both prerelease: lexicographic fallback on the tag.
|
||||
const ta = pa.pre
|
||||
const tb = pb.pre
|
||||
if (ta === tb) return 0
|
||||
if (ta === '') return 1
|
||||
if (tb === '') return -1
|
||||
return ta < tb ? -1 : ta > tb ? 1 : 0
|
||||
}
|
||||
|
||||
interface ParsedSemver {
|
||||
num: [number, number, number]
|
||||
pre: string
|
||||
}
|
||||
|
||||
function parseSemver(version: string): ParsedSemver {
|
||||
const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
||||
if (!m) return { num: [0, 0, 0], pre: '' }
|
||||
return {
|
||||
num: [Number(m[1]), Number(m[2]), Number(m[3])],
|
||||
pre: m[4] ?? '',
|
||||
}
|
||||
}
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
||||
* BOTH halves: the host registers the schemastery schema over these values
|
||||
* (config.ts) and the client reads/writes them through the settings RPC
|
||||
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
||||
* the browser bundle never pulls the schema runtime in.
|
||||
*/
|
||||
|
||||
/** The user-settings namespace holding the side card preferences. */
|
||||
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
|
||||
|
||||
/** User-facing side card preferences (new-conversation defaults). */
|
||||
export interface SidebarPrefs {
|
||||
/** Whether a brand-new conversation opens the side card by default. */
|
||||
openByDefault: boolean
|
||||
/** Default panel width as a percent of the window width (20–60). */
|
||||
defaultWidthPercent: number
|
||||
/**
|
||||
* Whether the sidebar auto-activates (opens the panel) and expands the
|
||||
* Subagent page when the current conversation spawns a new subagent.
|
||||
*/
|
||||
autoOpenSubagent: boolean
|
||||
/**
|
||||
* Whether the model-facing agent terminal tools (terminal_create / list /
|
||||
* send / read / wait_for / resize / signal / close) are injected into the
|
||||
* model's toolset. Off by default: the feature stays dormant until the
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
|
||||
* and derived flows like subagent auto-open / agent-terminal tabs stop).
|
||||
* Already-open tabs of a disabled type keep rendering (closing one
|
||||
* prevents reopening), matching the "existing conversations keep their
|
||||
* own layouts" rule.
|
||||
*/
|
||||
tabsEnabled: Record<string, boolean>
|
||||
/**
|
||||
* Per-viewer enable switches, keyed by file viewer descriptor id
|
||||
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
|
||||
* viewer is skipped by `matchFileViewer` so files fall through to the
|
||||
* next matching viewer (or the download button when none match).
|
||||
*/
|
||||
viewersEnabled: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
export function clampWidthPercent(value: number): number {
|
||||
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* PTY session table for the sidebar terminals. One node-pty process per
|
||||
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
||||
* (page refresh, tab switch) and reconnect to the same process by key.
|
||||
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
||||
* connection replays history before live data. Sessions die only when the
|
||||
* tab is closed or the plugin tears down.
|
||||
*/
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-terminal transcript bound (bytes kept for replay). */
|
||||
const TRANSCRIPT_LIMIT = 1 << 20
|
||||
|
||||
/**
|
||||
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
||||
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
||||
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
||||
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
|
||||
* plugin activation so link-installed deployments get the fix too.
|
||||
*/
|
||||
export function ensureSpawnHelper(): void {
|
||||
if (process.platform === 'win32') return
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const entry = require.resolve('node-pty')
|
||||
const packageRoot = dirname(dirname(entry))
|
||||
const candidates = [
|
||||
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
||||
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
||||
]
|
||||
for (const helper of candidates) {
|
||||
if (existsSync(helper)) chmodSync(helper, 0o755)
|
||||
}
|
||||
} catch {
|
||||
// Resolution or chmod failure: the terminal surfaces its own spawn error.
|
||||
}
|
||||
}
|
||||
|
||||
/** One live terminal. */
|
||||
export interface SidebarPty {
|
||||
/** `${sessionId}:${tabId}` registry key. */
|
||||
key: string
|
||||
sessionId: string
|
||||
tabId: string
|
||||
/** The working directory the process was SPAWNED with (a reconnect that
|
||||
* resolves a different authoritative cwd respawns instead of reusing —
|
||||
* the page-load hydrate race can attach the real cwd after the first
|
||||
* connect, and a shell in the wrong directory must not linger). */
|
||||
cwd: string
|
||||
pty: nodePty.IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
exited: boolean
|
||||
exitCode?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
||||
* conversation (the client caps tabs at the same number).
|
||||
*/
|
||||
export class PtyManager {
|
||||
private readonly sessions = new Map<string, SidebarPty>()
|
||||
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
private readonly maxPerSession: number,
|
||||
) {}
|
||||
|
||||
/** All live terminal keys of one session. */
|
||||
keysOf(sessionId: string): string[] {
|
||||
const keys: string[] = []
|
||||
for (const handle of this.sessions.values()) {
|
||||
if (handle.sessionId === sessionId) keys.push(handle.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
||||
* process already exited is replaced with a fresh spawn (reconnecting a
|
||||
* dead terminal must yield a live shell, not an input sink), and so is a
|
||||
* live handle whose spawn cwd differs from the now-authoritative one (the
|
||||
* first connect of a page load can arrive before the session hydrates, so
|
||||
* it fell back to the process cwd — reconnecting with the real cwd must
|
||||
* restart the shell in the right directory). Reopening also cancels any
|
||||
* pending scheduled close (a reconnect within the grace window keeps the
|
||||
* process alive).
|
||||
* @param sessionId - conversation id.
|
||||
* @param tabId - client tab id.
|
||||
* @param cwd - initial working directory (the session's cwd).
|
||||
* @param cols - initial terminal width.
|
||||
* @param rows - initial terminal height.
|
||||
* @returns the live handle.
|
||||
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
||||
*/
|
||||
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
|
||||
const key = `${sessionId}:${tabId}`
|
||||
this.cancelClose(key)
|
||||
const existing = this.sessions.get(key)
|
||||
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
|
||||
if (existing !== undefined) this.close(key)
|
||||
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
|
||||
// on an old host without the close frame) must not eat the quota.
|
||||
for (const [candidate, handle] of [...this.sessions]) {
|
||||
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
|
||||
}
|
||||
if (this.keysOf(sessionId).length >= this.maxPerSession) {
|
||||
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
|
||||
}
|
||||
const handle: SidebarPty = {
|
||||
key,
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: nodePty.spawn(this.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
}),
|
||||
transcript: '',
|
||||
exited: false,
|
||||
}
|
||||
handle.pty.onData((data) => {
|
||||
handle.transcript += data
|
||||
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
||||
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
||||
}
|
||||
})
|
||||
handle.pty.onExit(({ exitCode }) => {
|
||||
handle.exited = true
|
||||
handle.exitCode = exitCode
|
||||
})
|
||||
this.sessions.set(key, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
||||
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
||||
* crash) uses the grace period so a quick reconnect keeps the process.
|
||||
* `open()` cancels any pending close.
|
||||
*/
|
||||
scheduleClose(key: string, delayMs: number): void {
|
||||
const handle = this.sessions.get(key)
|
||||
if (handle === undefined) return
|
||||
this.cancelClose(key)
|
||||
const timer = setTimeout(() => { this.close(key) }, delayMs)
|
||||
this.pendingCloses.set(key, timer)
|
||||
}
|
||||
|
||||
/** Cancel a pending scheduled close (the terminal is being reopened). */
|
||||
cancelClose(key: string): void {
|
||||
const timer = this.pendingCloses.get(key)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
this.pendingCloses.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a live handle by key, or undefined. */
|
||||
get(key: string): SidebarPty | undefined {
|
||||
return this.sessions.get(key)
|
||||
}
|
||||
|
||||
/** Close a terminal and drop its state (the owning tab was closed). */
|
||||
close(key: string): void {
|
||||
this.cancelClose(key)
|
||||
const handle = this.sessions.get(key)
|
||||
if (handle === undefined) return
|
||||
this.sessions.delete(key)
|
||||
try {
|
||||
handle.pty.kill()
|
||||
} catch {
|
||||
// Already exited or gone; nothing left to kill.
|
||||
}
|
||||
}
|
||||
|
||||
/** Close every terminal (plugin teardown). */
|
||||
disposeAll(): void {
|
||||
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
||||
this.pendingCloses.clear()
|
||||
for (const key of [...this.sessions.keys()]) this.close(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** The interactive shell for this platform (empty SHELL falls back). */
|
||||
export function defaultShell(): string {
|
||||
if (process.platform === 'win32') return 'powershell.exe'
|
||||
const shell = process.env.SHELL
|
||||
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
|
||||
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
|
||||
* close, list). Each tool binds to the calling agent's session through
|
||||
* `exec.agent.session.id`, so the model never passes a sessionId — the
|
||||
* agent identity is the scope.
|
||||
*
|
||||
* Conventions (per plugin-development-guide.md §3):
|
||||
* C1 — parameters schema-validated before `execute` runs.
|
||||
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
||||
* pure text projection.
|
||||
* C6 — `exec.signal.throwIfAborted()` before any spawn.
|
||||
* C10 — no UI/transport vocabulary in the canonical value.
|
||||
*/
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Context } from './context-types.ts'
|
||||
import {
|
||||
AgentPtyRegistry,
|
||||
ALLOWED_SIGNALS,
|
||||
type AgentTerminalSignal,
|
||||
type AgentTerminalSnapshot,
|
||||
} from './agent-pty.ts'
|
||||
|
||||
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
|
||||
const READ_BYTE_LIMIT = 256 * 1024
|
||||
|
||||
/**
|
||||
* Bound a string to a byte limit, marking truncation. Truncation never
|
||||
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
|
||||
* the walk-back retreats to the sequence's leading byte so the retained
|
||||
* prefix decodes cleanly (a split would decode to U+FFFD).
|
||||
* @internal exported for the unit tests, like {@link snapshotOf}.
|
||||
*/
|
||||
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
const buf = Buffer.from(text, 'utf8')
|
||||
if (buf.byteLength <= maxBytes) return { text, truncated: false }
|
||||
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
|
||||
// character that started before `end` spills past the cap. Stop at the
|
||||
// first non-continuation byte — the retained prefix is then intact.
|
||||
let end = maxBytes
|
||||
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
|
||||
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
|
||||
}
|
||||
|
||||
/** Pure text projection helper (the canonical value is already structured). */
|
||||
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
|
||||
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
|
||||
}
|
||||
|
||||
/** Extract the calling agent or throw the canonical "no agent" error. */
|
||||
function requireAgent(agent: Agent | undefined): Agent {
|
||||
if (agent === undefined) {
|
||||
throw new Error('sidebar terminal tools require an initiating agent')
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve the calling agent's session id (the registry scope + ownership key). */
|
||||
function sessionIdOf(exec: ToolRunContext): string {
|
||||
return requireAgent(exec.agent).session.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the eight terminal tools against the host tool registry. The
|
||||
* `resolveCwd` callback threads the live session cwd (authoritative from the
|
||||
* session store, falling back to the process cwd) so a freshly-created
|
||||
* terminal lands in the right directory without the model passing it.
|
||||
* Every uuid-keyed tool first asserts the terminal belongs to the calling
|
||||
* session (`registry.assertOwned`), so one agent can never reach another
|
||||
* session's terminals.
|
||||
* @param ctx - host plugin context (carries the tools service).
|
||||
* @param registry - the agent-owned terminal registry.
|
||||
* @param resolveCwd - live cwd resolver for one session id.
|
||||
* @returns a disposer that unregisters all eight tools (the caller gates
|
||||
* registration on the side-card setting and calls this to turn them off).
|
||||
*/
|
||||
export function registerTools(
|
||||
ctx: Context,
|
||||
registry: AgentPtyRegistry,
|
||||
resolveCwd: (sessionId: string) => string,
|
||||
): () => void {
|
||||
const disposers: Array<() => void> = []
|
||||
const register = (tool: ReturnType<typeof defineTool>): void => {
|
||||
disposers.push(ctx.tools.register(tool))
|
||||
}
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_create',
|
||||
description:
|
||||
'Open a persistent terminal in the sidebar and run a command in it. '
|
||||
+ 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. '
|
||||
+ 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), '
|
||||
+ 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), '
|
||||
+ 'and close it with terminal_close when done. '
|
||||
+ 'Use this for interactive shells, REPLs, long-running dev servers, '
|
||||
+ 'or any work that needs persistent terminal state across tool calls. '
|
||||
+ 'The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.',
|
||||
parameters: {
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").',
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass "" to open a bare shell with no command.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' },
|
||||
title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' },
|
||||
},
|
||||
},
|
||||
render: textRender((v: { uuid: string; title: string }) =>
|
||||
`Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`,
|
||||
),
|
||||
},
|
||||
execute: (args: { title: string; command: string }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = resolveCwd(sessionId)
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
|
||||
return Promise.resolve({ uuid, title: args.title })
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_list',
|
||||
description:
|
||||
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
|
||||
+ 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). '
|
||||
+ 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true },
|
||||
title: { type: 'string', required: true },
|
||||
command: { type: 'string', required: true },
|
||||
exited: { type: 'boolean', required: true },
|
||||
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const list = value as AgentTerminalSnapshot[]
|
||||
if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }]
|
||||
const lines = list.map((t) => {
|
||||
const status = t.exited
|
||||
? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})`
|
||||
: 'running'
|
||||
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`
|
||||
})
|
||||
return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }]
|
||||
},
|
||||
},
|
||||
execute: (_args, exec) => {
|
||||
const sessionId = sessionIdOf(exec)
|
||||
return Promise.resolve(registry.list(sessionId))
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_send',
|
||||
description:
|
||||
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
|
||||
+ 'The text is written verbatim to the pty stdin. '
|
||||
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
|
||||
+ 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. '
|
||||
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
|
||||
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
|
||||
+ 'Throws if the terminal has exited.',
|
||||
parameters: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Terminal uuid from terminal_create or terminal_list.',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.',
|
||||
},
|
||||
submit: {
|
||||
type: 'boolean',
|
||||
description: 'Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true },
|
||||
bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' },
|
||||
},
|
||||
},
|
||||
render: textRender((v: { uuid: string; bytes: number }) =>
|
||||
`Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`,
|
||||
),
|
||||
},
|
||||
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const payload = args.submit === true ? `${args.text}\r` : args.text
|
||||
registry.send(args.uuid, payload)
|
||||
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_read',
|
||||
description:
|
||||
'Read a bounded page of retained output from an agent terminal without sending input. '
|
||||
+ 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. '
|
||||
+ 'Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). '
|
||||
+ 'Returns `totalLines` so you know how much scrollback remains. '
|
||||
+ 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.',
|
||||
parameters: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Terminal uuid from terminal_create or terminal_list.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: '0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines).',
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Maximum lines to return (default 500, hard cap 500).',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' },
|
||||
totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' },
|
||||
lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' },
|
||||
lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' },
|
||||
truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean }
|
||||
const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]`
|
||||
return [{ type: 'text', text: `${head}\n${v.text}` }]
|
||||
},
|
||||
},
|
||||
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const result = registry.read(args.uuid, args.offset, args.count)
|
||||
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
|
||||
return Promise.resolve({
|
||||
text: bounded.text,
|
||||
totalLines: result.totalLines,
|
||||
lineBegin: result.lineBegin,
|
||||
lineEnd: result.lineEnd,
|
||||
truncated: bounded.truncated,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_wait_for',
|
||||
description:
|
||||
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
|
||||
+ 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
|
||||
+ 'without busy-polling terminal_read. '
|
||||
+ 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. '
|
||||
+ 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
|
||||
+ 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
|
||||
+ 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
|
||||
parameters: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Terminal uuid from terminal_create or terminal_list.',
|
||||
},
|
||||
needle: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
|
||||
},
|
||||
timeout_ms: {
|
||||
type: 'number',
|
||||
description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'found' },
|
||||
needle: { type: 'string', required: true },
|
||||
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
|
||||
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
|
||||
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'timeout' },
|
||||
needle: { type: 'string', required: true },
|
||||
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
|
||||
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'exited' },
|
||||
needle: { type: 'string', required: true },
|
||||
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
|
||||
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
|
||||
if (v.kind === 'found') {
|
||||
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
|
||||
}
|
||||
if (v.kind === 'timeout') {
|
||||
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
|
||||
}
|
||||
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
|
||||
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
|
||||
},
|
||||
},
|
||||
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
// The registry validates the needle (empty → bad-request) and returns
|
||||
// the camelCase result the schema above declares directly — no
|
||||
// field-by-field projection.
|
||||
const timeoutMs = args.timeout_ms ?? 10_000
|
||||
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_resize',
|
||||
description:
|
||||
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
|
||||
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
|
||||
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
|
||||
parameters: {
|
||||
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
||||
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
|
||||
rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true },
|
||||
cols: { type: 'integer', required: true },
|
||||
rows: { type: 'integer', required: true },
|
||||
},
|
||||
},
|
||||
render: textRender((v: { uuid: string; cols: number; rows: number }) =>
|
||||
`Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`,
|
||||
),
|
||||
},
|
||||
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const dims = registry.resize(args.uuid, args.cols, args.rows)
|
||||
return Promise.resolve({ uuid: args.uuid, ...dims })
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_signal',
|
||||
description:
|
||||
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
|
||||
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
|
||||
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
|
||||
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
|
||||
+ 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. '
|
||||
+ 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.',
|
||||
parameters: {
|
||||
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
||||
signal: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ALLOWED_SIGNALS as readonly string[],
|
||||
description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true },
|
||||
signal: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) =>
|
||||
`Sent ${v.signal} to terminal ${v.uuid}.`,
|
||||
),
|
||||
},
|
||||
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
registry.signal(args.uuid, args.signal)
|
||||
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
|
||||
},
|
||||
}))
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_close',
|
||||
description:
|
||||
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
|
||||
+ 'Idempotent: closing an already-closed uuid is a no-op. '
|
||||
+ 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. '
|
||||
+ 'Always close terminals you no longer need — the host keeps the pty alive until you do.',
|
||||
parameters: {
|
||||
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
uuid: { type: 'string', required: true },
|
||||
closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' },
|
||||
},
|
||||
},
|
||||
render: textRender((v: { uuid: string; closed: boolean }) =>
|
||||
v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`,
|
||||
),
|
||||
},
|
||||
execute: (args: { uuid: string }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const closed = registry.close(args.uuid)
|
||||
return Promise.resolve({ uuid: args.uuid, closed })
|
||||
},
|
||||
}))
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Browser-trust fence for the sidebar routes, behaviorally identical to the
|
||||
* /api gateway's fence in @deepseek-ai/dsh-client-connection
|
||||
* (src/api-request-trust.ts + src/loopback-hostname.ts, BSD-3-Clause,
|
||||
* copied here because the package does not export these helpers and the
|
||||
* plugin must not depend on its internals). Host-header loopback or a
|
||||
* configured trusted authority passes; cross-site browser markers refuse.
|
||||
* This is a DNS-rebinding / cross-site defense, not authentication.
|
||||
*/
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
||||
function parseAuthority(authority: string): URL | undefined {
|
||||
try {
|
||||
return new URL(`http://${authority}`)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a normalized URL hostname names the local loopback authority. */
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
||||
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
||||
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
||||
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
||||
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
||||
return trustedHosts.some((entry) => {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl === undefined) return false
|
||||
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
||||
? entryUrl.hostname === hostUrl.hostname
|
||||
: entryUrl.host === hostUrl.host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one sidebar request may reach the plugin routes.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves.
|
||||
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
const host = header(request.headers, 'host')
|
||||
if (host === undefined) return false
|
||||
const hostUrl = parseAuthority(host)
|
||||
if (hostUrl === undefined) return false
|
||||
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
if (origin === undefined) return true
|
||||
try {
|
||||
return new URL(origin).host === hostUrl.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Wire helpers for the /sidebar JSON API: bounded body reading, response
|
||||
* writing, and the shared error envelope. Every API method returns
|
||||
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
|
||||
* (HTTP 4xx/5xx matching the code) on failure.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Machine-readable error codes of the sidebar API. */
|
||||
export type SidebarErrorCode =
|
||||
| 'bad-request'
|
||||
| 'not-found'
|
||||
| 'forbidden'
|
||||
| 'method-error'
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
/** One API failure with its wire code and HTTP status. */
|
||||
export class SidebarError extends Error {
|
||||
constructor(
|
||||
readonly code: SidebarErrorCode,
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Body size bound of one JSON request (defense against unbounded reads). */
|
||||
const MAX_BODY_BYTES = 1 << 20
|
||||
|
||||
/** Success envelope of one API method. */
|
||||
export interface SidebarOk<T> { ok: true; value: T }
|
||||
|
||||
/** Failure envelope of one API method. */
|
||||
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
|
||||
|
||||
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
||||
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
for await (const chunk of req) {
|
||||
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
total += buffer.length
|
||||
if (total > MAX_BODY_BYTES) {
|
||||
throw new SidebarError('bad-request', 'request body too large')
|
||||
}
|
||||
chunks.push(buffer)
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if (text.trim() === '') return {}
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', 'request body is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a JSON response with the given status. */
|
||||
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
/** Write the success envelope. */
|
||||
export function writeOk(res: ServerResponse, value: unknown): void {
|
||||
writeJson(res, 200, { ok: true, value })
|
||||
}
|
||||
|
||||
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
||||
export function writeError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof SidebarError) {
|
||||
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
|
||||
}
|
||||
|
||||
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
||||
export function requireString(payload: unknown, key: string): string {
|
||||
const record = payload as Record<string, unknown> | null
|
||||
const value = record?.[key]
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
throw new SidebarError('bad-request', `missing or invalid "${key}"`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager
|
||||
* smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit
|
||||
* tracking, and change-event subscription. The "AttachConsole failed"
|
||||
* console noise from node-pty's ConPTY module on Windows is expected and
|
||||
* does not affect the assertions.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts'
|
||||
|
||||
/**
|
||||
* Resolve a shell binary for tests: on Windows use PowerShell (available on
|
||||
* every modern Windows host), on POSIX use /bin/sh (universally available).
|
||||
*/
|
||||
function testShell(): string {
|
||||
return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'
|
||||
}
|
||||
|
||||
/** Wait for a terminal's transcript to contain a substring (or timeout). */
|
||||
async function waitForTranscript(
|
||||
registry: AgentPtyRegistry,
|
||||
uuid: string,
|
||||
needle: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const handle = registry.get(uuid)
|
||||
if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
const handle = registry.get(uuid)
|
||||
return handle?.transcript ?? ''
|
||||
}
|
||||
|
||||
describe('AgentPtyRegistry', () => {
|
||||
it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24)
|
||||
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
||||
const list = registry.list('s1')
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]!.uuid).toBe(uuid)
|
||||
expect(list[0]!.title).toBe('echo test')
|
||||
expect(list[0]!.command).toBe('echo hello-agent-pty')
|
||||
expect(list[0]!.exited).toBe(false)
|
||||
// The command was written to stdin; wait for the output.
|
||||
const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty')
|
||||
expect(transcript).toContain('hello-agent-pty')
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns a bare shell when command is empty', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24)
|
||||
const handle = registry.get(uuid)
|
||||
expect(handle).toBeDefined()
|
||||
expect(handle!.command).toBe('')
|
||||
expect(handle!.exited).toBe(false)
|
||||
// The pty process exists; its transcript may take a moment to
|
||||
// accumulate on Windows ConPTY. The handle is alive and listed.
|
||||
expect(registry.list('s1')).toHaveLength(1)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends raw text to stdin (send-keys semantics)', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24)
|
||||
// Wait for the shell prompt, then send a command.
|
||||
await waitForTranscript(registry, uuid, '', 1000)
|
||||
// The registry's send() writes verbatim; the caller (tool layer) is
|
||||
// responsible for appending \r when submit=true. Here we test the
|
||||
// registry directly: send text + \r to submit.
|
||||
registry.send(uuid, 'echo sent-via-send\r')
|
||||
const transcript = await waitForTranscript(registry, uuid, 'sent-via-send')
|
||||
expect(transcript).toContain('sent-via-send')
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a bounded page of the transcript', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, 'line3')
|
||||
const page = registry.read(uuid)
|
||||
expect(page.totalLines).toBeGreaterThan(0)
|
||||
expect(page.text).toContain('line1')
|
||||
expect(page.text).toContain('line3')
|
||||
// Negative offset reads from the end.
|
||||
const tail = registry.read(uuid, -2)
|
||||
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
|
||||
expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('resizes without throwing', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24)
|
||||
expect(() => registry.resize(uuid, 120, 40)).not.toThrow()
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
|
||||
// Oversized / undersized requests report the clamped values, so the
|
||||
// echoed result always matches the pty (the tool contract).
|
||||
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
|
||||
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
|
||||
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
|
||||
// create clamps through the same helper.
|
||||
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
|
||||
expect(registry.get(other)!.pty.cols).toBe(2)
|
||||
expect(registry.get(other)!.pty.rows).toBe(1024)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('assertOwned rejects a uuid owned by another session', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
|
||||
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
|
||||
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
|
||||
// A foreign uuid is indistinguishable from an unknown one.
|
||||
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
|
||||
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a terminal and removes it from the list', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24)
|
||||
expect(registry.list('s1')).toHaveLength(1)
|
||||
const closed = registry.close(uuid)
|
||||
expect(closed).toBe(true)
|
||||
expect(registry.list('s1')).toHaveLength(0)
|
||||
// Closing again is a no-op.
|
||||
const closedAgain = registry.close(uuid)
|
||||
expect(closedAgain).toBe(false)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes list by session id', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
registry.create('s1', 'a', '', process.cwd(), 80, 24)
|
||||
registry.create('s1', 'b', '', process.cwd(), 80, 24)
|
||||
registry.create('s2', 'c', '', process.cwd(), 80, 24)
|
||||
expect(registry.list('s1')).toHaveLength(2)
|
||||
expect(registry.list('s2')).toHaveLength(1)
|
||||
expect(registry.list('s3')).toHaveLength(0)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('fires change listeners on create, close, and exit', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
let changeCount = 0
|
||||
registry.subscribe(() => { changeCount += 1 })
|
||||
const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24)
|
||||
// create fires one change.
|
||||
expect(changeCount).toBeGreaterThanOrEqual(1)
|
||||
registry.close(uuid)
|
||||
// close fires another change.
|
||||
expect(changeCount).toBeGreaterThanOrEqual(2)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('disposeAll closes every terminal', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
registry.create('s1', 'a', '', process.cwd(), 80, 24)
|
||||
registry.create('s2', 'b', '', process.cwd(), 80, 24)
|
||||
registry.disposeAll()
|
||||
expect(registry.list('s1')).toHaveLength(0)
|
||||
expect(registry.list('s2')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('snapshotOf drops the pty reference and transcript', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24)
|
||||
const handle = registry.get(uuid)!
|
||||
const snap = snapshotOf(handle)
|
||||
expect(snap.uuid).toBe(uuid)
|
||||
expect(snap.title).toBe('snap')
|
||||
expect(snap.exited).toBe(false)
|
||||
// No pty or transcript fields on the snapshot.
|
||||
expect('pty' in snap).toBe(false)
|
||||
expect('transcript' in snap).toBe(false)
|
||||
// sessionId is registry-internal ownership, never serialized (it would
|
||||
// violate the terminal_list output schema's additionalProperties:false).
|
||||
expect('sessionId' in snap).toBe(false)
|
||||
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => {
|
||||
expect(ALLOWED_SIGNALS).toContain('SIGINT')
|
||||
expect(ALLOWED_SIGNALS).toContain('SIGTERM')
|
||||
expect(ALLOWED_SIGNALS).toContain('SIGKILL')
|
||||
expect(ALLOWED_SIGNALS).toContain('SIGHUP')
|
||||
expect(ALLOWED_SIGNALS).toContain('SIGTSTP')
|
||||
})
|
||||
|
||||
it('waitFor returns found when the needle is already in the transcript', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, 'wait-for-fast')
|
||||
// The needle is already present; waitFor should return immediately.
|
||||
const result = await registry.waitFor(uuid, 'wait-for-fast', 2000)
|
||||
expect(result.kind).toBe('found')
|
||||
if (result.kind === 'found') {
|
||||
expect(result.needle).toBe('wait-for-fast')
|
||||
expect(result.elapsedMs).toBeLessThan(500)
|
||||
}
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('waitFor returns found after the needle appears (async output)', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
// Spawn a shell WITH a command (so output is guaranteed), but start
|
||||
// the wait for a DIFFERENT needle that only appears after we send
|
||||
// a follow-up command. This exercises the subscribe/poll path: the
|
||||
// wait must notice output arriving AFTER it started, not just find
|
||||
// pre-existing content.
|
||||
const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24)
|
||||
// Wait for the initial command's output so we know the shell is live.
|
||||
await waitForTranscript(registry, uuid, 'shell-ready', 10_000)
|
||||
// Start the wait BEFORE sending the follow-up command. The needle is
|
||||
// NOT in the transcript yet. ConPTY under test concurrency can be slow,
|
||||
// so the timeout is generous.
|
||||
const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000)
|
||||
// Give the wait a moment to subscribe, then send the command.
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r')
|
||||
const result = await waitPromise
|
||||
expect(result.kind).toBe('found')
|
||||
if (result.kind === 'found') {
|
||||
expect(result.needle).toBe('UNIQUE_NEEDLE_42')
|
||||
}
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
}, 40_000)
|
||||
|
||||
it('waitFor returns timeout when the needle never appears', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24)
|
||||
await waitForTranscript(registry, uuid, '', 1000)
|
||||
// Wait for a needle that will never appear; use a short timeout to
|
||||
// keep the test fast.
|
||||
const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500)
|
||||
expect(result.kind).toBe('timeout')
|
||||
if (result.kind === 'timeout') {
|
||||
expect(result.timeoutMs).toBe(500)
|
||||
expect(result.totalLines).toBeGreaterThan(0)
|
||||
}
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('waitFor throws on an empty needle', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24)
|
||||
await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow()
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('waitFor throws on an unknown uuid', async () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24)
|
||||
// SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty
|
||||
// stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z.
|
||||
// These must NOT throw (the old kill(SIGINT) path threw on Windows).
|
||||
expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow()
|
||||
expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow()
|
||||
// The control byte was written to the pty; the shell received it.
|
||||
// On POSIX the line discipline generates the signal; on Windows
|
||||
// ConPTY translates it. Either way the write path is exercised.
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers SIGKILL via the process-termination path', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24)
|
||||
// SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not
|
||||
// throw even when the pty's kill() rejects named signals — the
|
||||
// fallback to the default kill ensures the signal takes effect.
|
||||
expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow()
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Browser globals for specs that pull browser-bound modules. Import this
|
||||
* FIRST in a spec file: unlike statements after imports (which vitest
|
||||
* hoists above the module body), an import statement evaluates in order,
|
||||
* so this runs before any later import's module graph (xterm's UMD wrapper
|
||||
* checks `self` and its color defaults probe `document` at evaluation time;
|
||||
* window/localStorage are only touched at runtime by the store).
|
||||
*/
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
if (g.self === undefined) g.self = globalThis
|
||||
|
||||
const elementStub = (): Record<string, unknown> => ({
|
||||
style: {},
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
setAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
|
||||
// xterm's color palette probe measures a canvas context at module load.
|
||||
getContext: () => null,
|
||||
})
|
||||
|
||||
if (g.document === undefined) {
|
||||
g.document = {
|
||||
createElement: () => elementStub(),
|
||||
createDocumentFragment: () => elementStub(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
defaultView: globalThis,
|
||||
body: elementStub(),
|
||||
// CodeMirror's view module probes the UA and injects styles at load.
|
||||
documentElement: elementStub(),
|
||||
head: elementStub(),
|
||||
styleSheets: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (g.navigator === undefined) {
|
||||
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
|
||||
}
|
||||
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 8 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
|
||||
* viewer pin the registry's matching behavior.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerBuiltins({} as Context, service)
|
||||
return { service, dispose }
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
|
||||
})
|
||||
|
||||
it('single-instance tabs use the single sugar', () => {
|
||||
const { service } = setup()
|
||||
for (const id of ['explorer', 'git', 'subagent']) {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools related setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
|
||||
})
|
||||
|
||||
it('every built-in tab carries the settings-surface icon', () => {
|
||||
const { service } = setup()
|
||||
for (const tab of service.getTabs()) {
|
||||
expect(tab.icon, tab.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 8 built-in file viewers', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
)
|
||||
})
|
||||
|
||||
it('code is the catch-all at the lowest priority', () => {
|
||||
const { service } = setup()
|
||||
const code = service.getFileViewers().find(v => v.id === 'code')
|
||||
expect(code?.exts).toEqual([])
|
||||
expect(code?.priority).toBe(-100)
|
||||
expect(code?.fetchStrategy).toBe('fsRead')
|
||||
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('markdown claims md/markdown before the catch-all', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||||
})
|
||||
|
||||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||||
const { service } = setup()
|
||||
// First match (no head) falls to the catch-all code viewer...
|
||||
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
|
||||
// ...but the head re-match (NUL probe) routes it to binary-download.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
|
||||
// A NUL-free blob stays with code.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
|
||||
const { service } = setup()
|
||||
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
|
||||
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
|
||||
const { service } = setup()
|
||||
for (const viewer of service.getFileViewers()) {
|
||||
expect(viewer.title, viewer.id).toBeDefined()
|
||||
expect(viewer.icon, viewer.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in disposer', () => {
|
||||
it('unregisters everything (HMR-safe)', () => {
|
||||
const { service, dispose } = setup()
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
// The disposer is idempotent.
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the editor-load planning: the pure strategy dispatch the
|
||||
* editor host runs (planFirstMatch / planFsReadOutcome / decodeHead).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts'
|
||||
import type { FileViewerDescriptor } from '../src/client/service.ts'
|
||||
|
||||
const viewer = (over: Partial<FileViewerDescriptor>): FileViewerDescriptor => ({
|
||||
id: 'v',
|
||||
exts: [],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: () => null,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('planFirstMatch', () => {
|
||||
it('no viewer → binary (download UI)', () => {
|
||||
expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary-download strategy → binary', () => {
|
||||
const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('mediaUrl / none → render with the media URL', () => {
|
||||
const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' })
|
||||
expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' })
|
||||
expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render')
|
||||
})
|
||||
|
||||
it('custom → customLoad (the viewer loads its own bytes)', () => {
|
||||
const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v })
|
||||
})
|
||||
|
||||
it('fsRead → fetchFsRead', () => {
|
||||
const v = viewer({ id: 'code', fetchStrategy: 'fsRead' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v })
|
||||
})
|
||||
})
|
||||
|
||||
describe('planFsReadOutcome', () => {
|
||||
const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
// The builtin binary-download NUL probe shape.
|
||||
if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' })
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('text result renders with content, same viewer', () => {
|
||||
const v = viewer({ id: 'code' })
|
||||
const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m')
|
||||
expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true })
|
||||
})
|
||||
|
||||
it('binary without head bytes → binary', () => {
|
||||
expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary whose NUL head re-matches nothing renderable → binary', () => {
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes
|
||||
rematch,
|
||||
() => '/m',
|
||||
)
|
||||
// binary-download claims it — still the download UI.
|
||||
expect(action).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => {
|
||||
const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => sniffer,
|
||||
() => '/m',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'customLoad', viewer: sniffer })
|
||||
})
|
||||
|
||||
it('binary re-matched to a mediaUrl viewer → render with the media URL', () => {
|
||||
const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => media,
|
||||
() => '/m?x=1',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeHead', () => {
|
||||
it('decodes base64 to the exact bytes', () => {
|
||||
const head = decodeHead('AP//AA==')
|
||||
expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00])
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Locale contract for the client half. The apes:* tabs are bilingual
|
||||
* (zh default, en fallback) — the same shape tutti-os ships. This spec pins
|
||||
* the i18n surface against the REAL locale module API:
|
||||
* - t() resolves the active locale, falling back en → raw key
|
||||
* - registerLocales() syncs activeLocale from a mock ApesLocaleService and
|
||||
* keeps it in sync as the runtime emits new snapshots
|
||||
* - subscribeLocale() re-render hook + clean detach
|
||||
* The React hook (useApesLocale) needs a renderer; it is a thin
|
||||
* useSyncExternalStore wrapper over the same getSnapshot/subscribe pair and
|
||||
* is exercised indirectly here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
t,
|
||||
getActiveLocale,
|
||||
subscribeLocale,
|
||||
registerLocales,
|
||||
} from '../src/client/locales.ts'
|
||||
import type { ApesLocaleService } from '../src/context-types.ts'
|
||||
|
||||
/** Build a mock ApesLocaleService whose active locale we can flip. */
|
||||
function makeLocaleService(initialActive = 'zh'): {
|
||||
service: ApesLocaleService
|
||||
setActive: (loc: string) => void
|
||||
registered: Array<{ ns: string; locale: string }>
|
||||
} {
|
||||
let active = initialActive
|
||||
const subs = new Set<() => void>()
|
||||
const registered: Array<{ ns: string; locale: string }> = []
|
||||
const service: ApesLocaleService = {
|
||||
getSnapshot: () => ({ active }),
|
||||
subscribe: (fn) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
register: (ns, locale) => {
|
||||
registered.push({ ns, locale })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
return {
|
||||
service,
|
||||
setActive: (loc) => {
|
||||
active = loc
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
registered,
|
||||
}
|
||||
}
|
||||
|
||||
describe('locales module (real API)', () => {
|
||||
it('resolves keys in the default zh locale', () => {
|
||||
// Before any registerLocales call, activeLocale is the module default.
|
||||
const active = getActiveLocale()
|
||||
expect(['zh', 'en']).toContain(active)
|
||||
// Every registered key resolves to a non-empty string.
|
||||
expect(t('tab.tasks.title').length).toBeGreaterThan(0)
|
||||
expect(t('tab.agents.title').length).toBeGreaterThan(0)
|
||||
// A key in neither locale falls back to the raw key (flag the gap).
|
||||
expect(t('does.not.exist')).toBe('does.not.exist')
|
||||
})
|
||||
|
||||
it('registerLocales registers zh+en under the apesSidebar namespace', () => {
|
||||
const { service, registered } = makeLocaleService('zh')
|
||||
const dispose = registerLocales(service)
|
||||
expect(registered.map((r) => r.locale).sort()).toEqual(['en', 'zh'])
|
||||
expect(registered.every((r) => r.ns === 'apesSidebar')).toBe(true)
|
||||
dispose() // clean disposer, no throw on double-run
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('registerLocales seeds activeLocale from the runtime snapshot', () => {
|
||||
const { service } = makeLocaleService('en')
|
||||
const dispose = registerLocales(service)
|
||||
expect(getActiveLocale()).toBe('en')
|
||||
expect(t('tab.tasks.title')).toBe('Tasks')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('keeps activeLocale in sync as the runtime emits a new active locale', () => {
|
||||
const { service, setActive } = makeLocaleService('zh')
|
||||
const dispose = registerLocales(service)
|
||||
expect(getActiveLocale()).toBe('zh')
|
||||
const before = t('tab.tasks.title')
|
||||
setActive('en')
|
||||
expect(getActiveLocale()).toBe('en')
|
||||
expect(t('tab.tasks.title')).toBe('Tasks')
|
||||
// The value actually changed.
|
||||
expect(before).not.toBe(t('tab.tasks.title'))
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('subscribeLocale notifies on locale change and detaches cleanly', () => {
|
||||
const { service, setActive } = makeLocaleService('zh')
|
||||
const dispose = registerLocales(service)
|
||||
const seen: string[] = []
|
||||
const unsub = subscribeLocale(() => seen.push(getActiveLocale()))
|
||||
setActive('en')
|
||||
expect(seen).toEqual(['en'])
|
||||
unsub()
|
||||
setActive('zh')
|
||||
// After detach, no further observation.
|
||||
expect(seen).toEqual(['en'])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('is a no-op (clean disposer) when the locale runtime is absent', () => {
|
||||
const dispose = registerLocales(undefined)
|
||||
expect(typeof dispose).toBe('function')
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('tolerates a runtime whose subscribe throws (early boot guard)', () => {
|
||||
const service: ApesLocaleService = {
|
||||
getSnapshot: () => ({ active: 'en' }),
|
||||
subscribe: () => {
|
||||
throw new Error('runtime not ready')
|
||||
},
|
||||
register: () => () => {},
|
||||
}
|
||||
// The try/catch in registerLocales swallows the subscribe failure and
|
||||
// still returns a working disposer.
|
||||
const dispose = registerLocales(service)
|
||||
expect(typeof dispose).toBe('function')
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Registry manifest consistency spec: guards the plugin-registry release form
|
||||
* (`dsh.plugin.json`) against the build output and package.json, so the repo
|
||||
* cannot drift into an uninstallable / uneable state:
|
||||
* - `id` passes the registry's strict two-segment validation (native form),
|
||||
* - `version` stays in sync with package.json,
|
||||
* - the manifest entry files exist in the build output,
|
||||
* - the registry client bundle registers exactly the manifest id (the
|
||||
* browser-side `arrive()` contract), and the official client bundle keeps
|
||||
* the package-name id (the client-modules compose contract) — the two
|
||||
* bundles must never be swapped.
|
||||
*
|
||||
* Reads the built lib/ output, so run `pnpm build` first (the registry
|
||||
* install validates main/client.main existence the same way).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
|
||||
interface PluginManifest {
|
||||
id: string
|
||||
version: string
|
||||
main: string
|
||||
description?: string
|
||||
engines?: { dsh?: string }
|
||||
contributes?: { tools?: string[]; skills?: string[] }
|
||||
client?: { main?: string; inject?: string[]; immediately?: boolean }
|
||||
}
|
||||
|
||||
interface PackageJson {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
|
||||
|
||||
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
|
||||
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
||||
|
||||
/** Literal require() specifiers the frozen browser module table can answer. */
|
||||
const CLIENT_REQUIRE_ALLOWED = new Set([
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
|
||||
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
||||
function bundleId(file: string): string {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
|
||||
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
it('id is a valid two-segment registry id (native form)', () => {
|
||||
expect(manifest.id).toMatch(ID_PATTERN)
|
||||
})
|
||||
|
||||
it('version matches package.json', () => {
|
||||
expect(manifest.version).toBe(pkg.version)
|
||||
})
|
||||
|
||||
it('main and client.main exist in the build output', () => {
|
||||
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
|
||||
expect(manifest.client?.main).toBeDefined()
|
||||
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
|
||||
})
|
||||
|
||||
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
|
||||
const registryId = bundleId(manifest.client!.main!)
|
||||
expect(registryId).toBe(manifest.id)
|
||||
expect(bundleId('lib/client.js')).toBe(pkg.name)
|
||||
expect(registryId).not.toBe(pkg.name)
|
||||
})
|
||||
|
||||
it('client bundles require only frozen module-table entries', () => {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!]) {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
// Exclude method calls such as `freeModule.require("util")`; only the
|
||||
// loader factory's lexical require() is constrained by the module table.
|
||||
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('contributes declares no tools or skills (the host half registers none)', () => {
|
||||
expect(manifest.contributes?.tools).toEqual([])
|
||||
expect(manifest.contributes?.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Orphaned-tab tests: a persisted tab whose type is not registered must
|
||||
* survive sanitize (title/path kept for recovery) and render the graceful
|
||||
* placeholder instead of crashing — the degradation path of the open
|
||||
* tab-type set (a contributing plugin not installed / not yet loaded).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { OrphanedTab } from '../src/client/OrphanedTab.tsx'
|
||||
import { sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
/** Collect every tab type in a sanitized tree (leaf/split recursive). */
|
||||
function typesOf(node: unknown): string[] {
|
||||
if (node === null || typeof node !== 'object') return []
|
||||
const record = node as { kind?: unknown; tabs?: unknown; children?: unknown }
|
||||
if (record.kind === 'leaf' && Array.isArray(record.tabs)) {
|
||||
return (record.tabs as Array<{ type: string }>).map(tab => tab.type)
|
||||
}
|
||||
if (Array.isArray(record.children)) return record.children.flatMap(typesOf)
|
||||
return []
|
||||
}
|
||||
|
||||
describe('sanitize keeps unregistered tab types', () => {
|
||||
it('preserves a persisted tab of an unknown type with its title/path', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [
|
||||
{ id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' },
|
||||
{ id: 'tab:2', type: 'explorer', title: 'Explorer' },
|
||||
],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
|
||||
// The kept tab keeps its payload so a later registration can reuse it.
|
||||
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
|
||||
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
|
||||
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
|
||||
})
|
||||
|
||||
it('an unknown type alone in a pane still sanitizes cleanly', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrphanedTab placeholder', () => {
|
||||
const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' }
|
||||
|
||||
it('renders the unregistered type and keeps the title', () => {
|
||||
const html = renderToString((
|
||||
<OrphanedTab
|
||||
ctx={undefined as never}
|
||||
store={undefined as never}
|
||||
scope={{ sessionId: 's1', cwd: '/p' }}
|
||||
tab={tab}
|
||||
visible={false}
|
||||
/>
|
||||
))
|
||||
expect(html).toContain('my-plugin:db')
|
||||
expect(html).toContain('Database')
|
||||
expect(html).not.toContain('undefined')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
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', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('exports the schemastery Config with the documented tunable fields', () => {
|
||||
const schema = sidebar.Config
|
||||
expect(schema).toBeDefined()
|
||||
// The resolved defaults mirror the pre-config constants.
|
||||
const resolved = (schema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.readLimit).toBe(512 * 1024)
|
||||
expect(resolved.mediaLimit).toBe(20 * 1024 * 1024)
|
||||
expect(resolved.listLimit).toBe(1000)
|
||||
expect(resolved.terminalsPerSession).toBe(3)
|
||||
expect(resolved.reconnectGraceMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
|
||||
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
}
|
||||
}
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
const dispose = service.registerTab({
|
||||
id: 'test:tab',
|
||||
title: 'Test',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getTabs()).toHaveLength(1)
|
||||
expect(service.getTab('test:tab')?.id).toBe('test:tab')
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getTab('test:tab')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registerTab throws on duplicate id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'dup', title: 'A', component: () => null })
|
||||
expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow()
|
||||
})
|
||||
|
||||
it('registerFileViewer adds and dispose removes', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
const dispose = service.registerFileViewer({
|
||||
id: 'csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getFileViewers()).toHaveLength(1)
|
||||
dispose()
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('subscribe fires on register and dispose', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
let calls = 0
|
||||
const unsub = service.subscribe(() => { calls++ })
|
||||
const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null })
|
||||
expect(calls).toBe(1)
|
||||
dispose()
|
||||
expect(calls).toBe(2)
|
||||
unsub()
|
||||
service.registerTab({ id: 'y', title: 'Y', component: () => null })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enable switches (declarative settings)', () => {
|
||||
/** A fresh store + service with one tab and one viewer registered. */
|
||||
const setup = () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
service.registerFileViewer({ id: 'image', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
it('an absent map key means enabled (default state)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
expect(service.isViewerEnabled('image')).toBe(true)
|
||||
// Unknown ids are enabled too (nothing registered → the check is open).
|
||||
expect(service.isTabEnabled('whatever')).toBe(true)
|
||||
})
|
||||
|
||||
it('only an explicit false disables a tab type', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(false)
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: true } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
})
|
||||
|
||||
it('openTab refuses a disabled tab type (no tab lands, no createTab)', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
|
||||
const { store, service } = setup()
|
||||
service.registerFileViewer({ id: 'code', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
// image claims .png; disabling it lets the catch-all take over.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
|
||||
// Disabling the catch-all too → no viewer at all.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false, code: false } })
|
||||
expect(service.matchFileViewer('photo.png')).toBeUndefined()
|
||||
// Re-enabling restores the image viewer.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: {} })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFileViewer', () => {
|
||||
it('matches by extension', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('photo.JPG')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('higher priority wins on extension conflict', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('advanced')
|
||||
})
|
||||
|
||||
it('catch-all (exts: []) matches anything at lowest priority', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
|
||||
})
|
||||
|
||||
it('detect claims files the viewer would otherwise miss, at its priority', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// by-magic does not match 'bin' by extension; only its detect (PNG magic)
|
||||
// can claim it — and only when head bytes are available.
|
||||
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 5, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'by-magic',
|
||||
exts: ['mag'],
|
||||
priority: 10,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// No head: by-magic's exts miss, by-ext claims .bin.
|
||||
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
|
||||
// Head with PNG magic: by-magic's detect fires at priority 10 before
|
||||
// by-ext (5) is consulted.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
|
||||
// Head without the magic: detect misses, by-ext claims .bin again.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x00, 0x50]))?.id).toBe('by-ext')
|
||||
})
|
||||
|
||||
it('priority decides first: a higher-priority exts match beats a lower-priority detect', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'by-magic',
|
||||
exts: ['mag'],
|
||||
priority: 5,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// Per-descriptor walk: by-ext (priority 10) claims .bin before by-magic's
|
||||
// detect (priority 5) is ever consulted — the design's priority-first rule.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext')
|
||||
})
|
||||
|
||||
it('a catch-all with detect is sniff-only: it never blind-claims without head', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'magic-sniffer',
|
||||
exts: [],
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// No head: the sniff-only catch-all yields — the real png viewer wins.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
// Head with the magic: the sniffer claims it (detect at priority 100).
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer')
|
||||
// Head without the magic: the sniffer yields again, img claims it.
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img')
|
||||
})
|
||||
|
||||
it('returns undefined when no viewer matches and no catch-all', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab dedupe', () => {
|
||||
it('dedupeKey focuses existing tab instead of duplicating', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'singleton',
|
||||
title: 'Singleton',
|
||||
dedupeKey: () => 'singleton',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('no dedupeKey opens a new tab for each distinct id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reopening with the same id focuses the existing tab (id safety net)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('createTab mints custom ids and patches state', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'counter',
|
||||
title: 'Counter',
|
||||
createTab: (state) => ({
|
||||
tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}),
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('counter:1')
|
||||
expect(tabs[1]!.id).toBe('counter:2')
|
||||
expect(state.nextTerminal).toBe(3)
|
||||
})
|
||||
|
||||
it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
it('the descriptor title is the default when no title is given', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'plain' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
|
||||
expect(tab?.title).toBe('Plain')
|
||||
})
|
||||
|
||||
it('single: true dedupes like dedupeKey: () => id (sugar)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton' })
|
||||
service.openTab({ type: 'singleton', id: 'singleton:extra' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an explicit dedupeKey wins over single: true', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
single: true,
|
||||
dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs).toHaveLength(2)
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
const state2 = store.getSnapshot().state!
|
||||
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs2).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('available receives ctx, scope and the live state (superset signature)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: unknown[] = []
|
||||
service.registerTab({
|
||||
id: 'gated',
|
||||
title: 'Gated',
|
||||
available: (ctx, scope, state) => {
|
||||
seen.push([ctx, scope, state])
|
||||
return true
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true)
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'diff',
|
||||
title: 'Diff',
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false }
|
||||
const paneId = store.getSnapshot().state!.activePane!
|
||||
// The git view's placement path (split surgery) opens the diff tab...
|
||||
store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed }))
|
||||
// ...and a service open of the same change focuses the existing tab
|
||||
// (the descriptor's dedupeKey is the same per-change id rule).
|
||||
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Side card settings section render tests: the section is DECLARATIVE —
|
||||
* every small card (icon, title, type id, extensions, on/off state) derives
|
||||
* from the sidebar service's tab/viewer registries instead of hardcoded
|
||||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||||
* the switch, the visual state IS the state (highlighted = enabled),
|
||||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||||
* Features that declare related settings carry a gear corner button whose
|
||||
* popup rows (native checkboxes) are tested through the extracted
|
||||
* FeatureSettingsRows component (the Modal portal renders only while open).
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'explorer',
|
||||
title: () => 'Explorer',
|
||||
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
|
||||
order: 10,
|
||||
component: () => null,
|
||||
})
|
||||
service.registerTab({
|
||||
id: 'subagent',
|
||||
title: () => 'Subagents',
|
||||
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
|
||||
order: 30,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto-open Subagents',
|
||||
desc: () => 'Expand on new subagent',
|
||||
}],
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
service.registerFileViewer({
|
||||
id: 'image',
|
||||
title: () => 'Image',
|
||||
icon: () => createElement('svg', { 'data-icon': 'image' }),
|
||||
exts: ['png', 'jpg'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: () => null,
|
||||
})
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
|
||||
return renderToString(createElement(
|
||||
SideCardSection,
|
||||
{ store, service } as unknown as SideCardSectionProps,
|
||||
))
|
||||
}
|
||||
|
||||
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
|
||||
function pressedCount(html: string, value: string): number {
|
||||
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
|
||||
}
|
||||
|
||||
describe('SideCardSection declarative inventory', () => {
|
||||
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="explorer"')
|
||||
expect(html).toContain('>Explorer<')
|
||||
// The type id is the card's desc (the declarative "type" surface).
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + both tabs + the image viewer are all
|
||||
// enabled → 4 cards pressed, none pressed=false. The nested auto-open
|
||||
// toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(4)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="image"')
|
||||
expect(html).toContain('>Image<')
|
||||
// The covered extensions are the card's desc.
|
||||
expect(html).toContain('png · jpg')
|
||||
})
|
||||
|
||||
it('renders the gear corner button on features that declare related settings', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
// Subagents declares a toggle → its card carries the settings gear
|
||||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||||
// none → no gear.
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('a disabled feature renders pressed=false', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
expect(pressedCount(html, 'true')).toBe(2)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
|
||||
...SIDEBAR_PREFS_DEFAULTS,
|
||||
autoOpenSubagent: false,
|
||||
}
|
||||
const toggles = [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto-open Subagents',
|
||||
desc: () => 'Expand on new subagent',
|
||||
}]
|
||||
|
||||
it('renders one native checkbox row per declared toggle with its current value', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
}))
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
// The checkbox reflects the prefs value (false here → unchecked).
|
||||
expect(html).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
it('checks the row when the pref is on', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs: { ...prefs, autoOpenSubagent: true },
|
||||
onToggle: () => {},
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
+586
-97
@@ -1,114 +1,603 @@
|
||||
/**
|
||||
* Phase 0 smoke spec: mounts the host plugin against a minimal fake context,
|
||||
* verifies the plugin export shape (no stray default, so the cordis Loader
|
||||
* keeps name/inject/apply), and exercises the invariant surface. Runs with
|
||||
* `pnpm test`.
|
||||
*
|
||||
* The client half (lib/client.js closure factory) is a browser artifact — it
|
||||
* registers tabs into the better-sidebar host registry at runtime, which
|
||||
* needs a live DSH shell. The client registration contract is therefore
|
||||
* covered by a focused unit spec (locales) + the build's purity gate; a full
|
||||
* mount smoke (cordis scratch profile) lands in Phase 1 with the real tabs.
|
||||
* Smoke spec: mounts the host plugin against a minimal fake context and
|
||||
* exercises the real integrations — route registration, git against the
|
||||
* actual repository, and a real directory listing. Runs with `pnpm test`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as host from '../src/index.ts'
|
||||
import {
|
||||
InvariantError,
|
||||
assert,
|
||||
assertBetterSidebarPeer,
|
||||
compareSemver,
|
||||
} from '../src/invariant.ts'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
|
||||
interface FakeContext {
|
||||
loader: { entries: () => never[] }
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => () => void
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
|
||||
}
|
||||
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
tools: { register: (tool: unknown) => () => void }
|
||||
effect: (fn: () => void | (() => void), label?: string) => void
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
it('serves PDF with the browser-native content type', () => {
|
||||
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
|
||||
expect(mediaTypeForPath('/work/archive.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
it('mounts the fenced routes', () => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const upgrades: SidebarWebUpgradeRoute[] = []
|
||||
const effects: Array<() => void | (() => void)> = []
|
||||
const ctx: FakeContext = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
tools: { register: () => () => {} },
|
||||
// The DSH-vendored cordis runs the registration effect immediately and
|
||||
// keeps its cleanup for disposal.
|
||||
effect: (fn) => {
|
||||
const cleanup = fn()
|
||||
if (typeof cleanup === 'function') effects.push(cleanup)
|
||||
},
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
})
|
||||
|
||||
it('runs git status/log/branches against this repository', async () => {
|
||||
const cwd = process.cwd()
|
||||
const status = await git.status(cwd)
|
||||
expect(status.isRepo).toBe(true)
|
||||
expect(typeof status.branch).toBe('string')
|
||||
expect(Array.isArray(status.entries)).toBe(true)
|
||||
const log = await git.log(cwd)
|
||||
expect(log.length).toBeGreaterThan(0)
|
||||
expect(log[0]!.hash).toMatch(/^[0-9a-f]{7,}$/)
|
||||
const branches = await git.branches(cwd)
|
||||
expect(branches.names).toContain(branches.current)
|
||||
})
|
||||
|
||||
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
|
||||
const cwd = process.cwd()
|
||||
const log = await git.log(cwd)
|
||||
const first = log[0]!
|
||||
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
|
||||
expect(typeof first.refs).toBe('string')
|
||||
const patch = await git.commitDiff(cwd, first.hashFull)
|
||||
expect(patch).toContain('diff --git')
|
||||
})
|
||||
|
||||
it('pages the log lazily with skip/count', async () => {
|
||||
const cwd = process.cwd()
|
||||
const first = await git.log(cwd, 5, 0)
|
||||
expect(first).toHaveLength(5)
|
||||
const second = await git.log(cwd, 5, 5)
|
||||
expect(second).toHaveLength(5)
|
||||
// The pages are disjoint windows over the same ordered history.
|
||||
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
|
||||
const all = await git.log(cwd, 10, 0)
|
||||
expect(all.slice(0, 5)).toEqual(first)
|
||||
expect(all.slice(5)).toEqual(second)
|
||||
// A skip past the end returns an empty page (the lazy loader's stop sign).
|
||||
expect(await git.log(cwd, 5, 10_000)).toEqual([])
|
||||
})
|
||||
|
||||
it('pty manager releases the quota on close and respawns after exit', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const first = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(manager.keysOf('s1')).toHaveLength(1)
|
||||
// Tab-close semantics (close frame): quota released immediately.
|
||||
manager.scheduleClose(first.key, 0)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(manager.keysOf('s1')).toHaveLength(0)
|
||||
// Reopen spawns a fresh process.
|
||||
const second = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(second).not.toBe(first)
|
||||
expect(manager.keysOf('s1')).toHaveLength(1)
|
||||
// After the shell exits, a reconnect respawns instead of reusing the dead handle.
|
||||
second.pty.write('exit\r')
|
||||
const deadline = Date.now() + 5000
|
||||
while (!second.exited && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
expect(second.exited).toBe(true)
|
||||
const third = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(third.exited).toBe(false)
|
||||
expect(third).not.toBe(second)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: exited zombie handles do not consume the quota', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 1)
|
||||
try {
|
||||
const first = manager.open('s3', 't1', process.cwd(), 80, 24)
|
||||
first.pty.write('exit\r')
|
||||
const deadline = Date.now() + 5000
|
||||
while (!first.exited && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
expect(first.exited).toBe(true)
|
||||
// Quota is 1; the exited handle is swept, so a NEW tab can still spawn.
|
||||
const second = manager.open('s3', 't2', process.cwd(), 80, 24)
|
||||
expect(second.exited).toBe(false)
|
||||
expect(manager.keysOf('s3')).toHaveLength(1)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
manager.scheduleClose(handle.key, 200)
|
||||
manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
expect(manager.get(handle.key)).toBeDefined()
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
// A real second directory: os.tmpdir() exists on every platform ('/tmp'
|
||||
// does not exist on Windows).
|
||||
const other = tmpdir()
|
||||
try {
|
||||
const first = manager.open('s4', 't1', process.cwd(), 80, 24)
|
||||
// The hydrate race: the first connect fell back to the process cwd,
|
||||
// the reconnect carries the session's real cwd — the shell must move.
|
||||
const second = manager.open('s4', 't1', other, 80, 24)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.cwd).toBe(other)
|
||||
expect(manager.keysOf('s4')).toHaveLength(1)
|
||||
// A same-cwd reconnect reattaches without respawning.
|
||||
const third = manager.open('s4', 't1', other, 80, 24)
|
||||
expect(third).toBe(second)
|
||||
expect(manager.keysOf('s4')).toHaveLength(1)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the repository root level', async () => {
|
||||
const listing = await listDirectory(process.cwd(), 1000)
|
||||
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
|
||||
expect(listing.entries.some(entry => entry.name === 'package.json' && !entry.isDir)).toBe(true)
|
||||
expect(listing.truncated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Run the real namespace export through `Loader.unwrapExports`; a stray
|
||||
* default would discard `name`, `inject`, and `apply`. Same guard the
|
||||
* official plugin repos ship.
|
||||
* Destructive git operations (discard / revert / cherry-pick) run against a
|
||||
* throwaway repository under the OS temp dir — never the plugin repo. The
|
||||
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
|
||||
* environment variables, confined to the fixture process: no git config is
|
||||
* touched anywhere (the plugin never sets an identity, and neither does its
|
||||
* test fixture).
|
||||
*/
|
||||
describe('apes-sidebar host plugin shape', () => {
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in host).toBe(false)
|
||||
expect(typeof host.apply).toBe('function')
|
||||
expect(host.name).toBe('apes-sidebar')
|
||||
// inject MUST be empty: `logger` is used only via optional-chaining in
|
||||
// apply(ctx) — declaring it as an injected service would make the entry
|
||||
// wait on a provider DSH host does not register, blocking activation
|
||||
// (recovery mode). See src/index.ts comment.
|
||||
expect(host.inject).toEqual([])
|
||||
describe('git destructive operations (scratch repository)', () => {
|
||||
const FIXTURE_IDENTITY = {
|
||||
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
|
||||
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
|
||||
}
|
||||
|
||||
const gitRun = (cwd: string, args: string[]): string => {
|
||||
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...FIXTURE_IDENTITY },
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
/** A fresh repo on branch `main` with one committed file `a.txt`. */
|
||||
const makeScratchRepo = (): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
|
||||
gitRun(dir, ['init', '-q'])
|
||||
gitRun(dir, ['checkout', '-q', '-b', 'main'])
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'base'])
|
||||
return dir
|
||||
}
|
||||
|
||||
it('discard restores the worktree file from the index (staged changes kept)', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
// Unstaged-only changes: fully reverts to the committed content.
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
|
||||
await git.discard(dir, 'a.txt')
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
|
||||
// Staged changes: the worktree snaps back to the STAGED content and
|
||||
// the index is untouched (`git checkout -- <path>` restores from the
|
||||
// index — VSCode's "Discard Changes" semantics).
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
await git.discard(dir, 'a.txt')
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
|
||||
const staged = await git.diff(dir, 'a.txt', true)
|
||||
expect(staged).toContain('-two')
|
||||
expect(staged).toContain('+CHANGED')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
|
||||
const logs: string[] = []
|
||||
let effects = 0
|
||||
it('revert creates a revert commit', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'change'])
|
||||
const featureHash = (await git.log(dir))[0]!.hashFull
|
||||
await git.revert(dir, featureHash)
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
|
||||
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cherry-pick applies a commit from another branch', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
|
||||
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
|
||||
const featureHash = (await git.log(dir))[0]!.hashFull
|
||||
gitRun(dir, ['checkout', '-q', 'main'])
|
||||
await git.cherryPick(dir, featureHash)
|
||||
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
|
||||
expect((await git.log(dir))[0]!.subject).toBe('feature work')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a failing destructive operation as a GitCommandError', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
// An unknown revision fails before touching anything.
|
||||
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
|
||||
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session cwd resolution over the API route', () => {
|
||||
interface CtxOverrides {
|
||||
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
}
|
||||
|
||||
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
logger: { info: (msg: string) => logs.push(msg) },
|
||||
effect: (fn: () => void | (() => void)) => {
|
||||
effects += 1
|
||||
const cleanup = fn()
|
||||
return () => {
|
||||
if (typeof cleanup === 'function') cleanup()
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: overrides.sessions ?? { get: () => undefined },
|
||||
tools: { register: () => () => {} },
|
||||
// The vendored cordis runs registration effects immediately.
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (
|
||||
route: SidebarWebRoute,
|
||||
method: string,
|
||||
payload: unknown,
|
||||
): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: `/sidebar/api/${method}`,
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
const out: { status: number; body: string } = { status: 200, body: '' }
|
||||
const res = {
|
||||
writeHead: (status: number) => { out.status = status },
|
||||
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
|
||||
} as never
|
||||
await route.handler(req, res)
|
||||
return JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { message: string } }
|
||||
}
|
||||
|
||||
it('uses the client summary cwd while the session is detached', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
// The summary cwd passes through requireAbsolute (platform resolve), so
|
||||
// the expectation follows the platform's own normalization.
|
||||
expect(result.value?.cwd).toBe(resolvePath('/tmp/summary-cwd'))
|
||||
})
|
||||
|
||||
it('falls back to the process cwd with no summary cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-unknown' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe(process.cwd())
|
||||
})
|
||||
|
||||
it('prefers the attached session header over the client summary', async () => {
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe('/attached-cwd')
|
||||
})
|
||||
|
||||
it('rejects a non-absolute client cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: 'relative/path' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/invalid working directory/)
|
||||
})
|
||||
|
||||
it('pty.close releases a terminal key (and rejects a missing tab)', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, 'pty.close', { sessionId: 's-pty', tab: 't1' })
|
||||
expect(result.ok).toBe(true)
|
||||
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
|
||||
expect(missing.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
|
||||
// The plugin repo's status paths are relative to the repo top level
|
||||
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
|
||||
// still load per-file diffs instead of failing with "not an absolute
|
||||
// path". The session header points INTO the repository.
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
|
||||
expect(result.ok).toBe(true)
|
||||
const value = result as unknown as { ok: boolean; value?: { diff: string } }
|
||||
expect(typeof value.value?.diff).toBe('string')
|
||||
})
|
||||
|
||||
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
|
||||
expect(result.ok).toBe(true)
|
||||
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
|
||||
expect(value.value?.kind).toBe('text')
|
||||
expect(value.value?.content).toContain('runGit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
const createFakeSettings = () => { const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
}>()
|
||||
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
|
||||
const schema = entry.schema as (input: unknown) => unknown
|
||||
return entry.value === undefined ? schema(undefined) : schema(entry.value)
|
||||
}
|
||||
return {
|
||||
register(ns: string, schema: unknown) {
|
||||
namespaces.set(ns, { schema, value: undefined, revision: 0 })
|
||||
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
|
||||
},
|
||||
describe() {
|
||||
return [...namespaces.entries()].map(([ns, entry]) => ({
|
||||
ns,
|
||||
value: resolve(entry),
|
||||
applies: 'live' as const,
|
||||
revision: entry.revision,
|
||||
}))
|
||||
},
|
||||
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
|
||||
const entry = namespaces.get(ns)
|
||||
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
|
||||
throw new SettingsConflictError(settingsNamespace(ns), expectedRevision, entry.revision)
|
||||
}
|
||||
entry.value = { ...entry.value, ...patch }
|
||||
entry.revision += 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
tools: { register: () => () => {} },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
|
||||
ok: boolean
|
||||
value?: unknown
|
||||
error?: { code?: string; message: string }
|
||||
}> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: `/sidebar/api/${method}`,
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
const out: { status: number; body: string } = { status: 200, body: '' }
|
||||
const res = {
|
||||
writeHead: (status: number) => { out.status = status },
|
||||
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
|
||||
} as never
|
||||
await route.handler(req, res)
|
||||
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
|
||||
}
|
||||
|
||||
it('serves the schema defaults when the settings service is absent', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ value: undefined, revision: undefined })
|
||||
})
|
||||
|
||||
it('reads the resolved prefs and writes a patch through the seam', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
},
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
expect(written.ok).toBe(true)
|
||||
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
|
||||
expect(view.value.openByDefault).toBe(false)
|
||||
expect(view.value.defaultWidthPercent).toBe(30)
|
||||
expect(view.revision).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a stale write with settings-conflict (409)', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
// The second write carries the pre-write revision: the seam refuses it.
|
||||
const stale = await invoke(route, 'settings.update', {
|
||||
patch: { defaultWidthPercent: 40 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(stale.ok).toBe(false)
|
||||
expect(stale.error?.code).toBe('settings-conflict')
|
||||
expect(stale.error?.message).toMatch(/changed since it was read/)
|
||||
})
|
||||
|
||||
it('rejects a non-object patch as bad-request', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const result = await invoke(route, 'settings.update', { patch: 'nope' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent terminal tool gating', () => {
|
||||
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
let disposed = 0
|
||||
// The tools currently registered (registered minus disposed).
|
||||
const live = (): number => registered - disposed
|
||||
// A ref container: the watch callback is only assigned inside a closure,
|
||||
// which TypeScript's control-flow analysis ignores (the bare variable
|
||||
// would narrow to null and refuse the optional call).
|
||||
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||
let enabled = false
|
||||
const settings = {
|
||||
register() {
|
||||
return {
|
||||
get: () => ({ agentTerminalTools: enabled }),
|
||||
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||
update: async () => {},
|
||||
replace: async () => {},
|
||||
}
|
||||
},
|
||||
describe: () => [],
|
||||
async update() {},
|
||||
}
|
||||
host.apply(ctx as never)
|
||||
expect(effects).toBe(1)
|
||||
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
|
||||
})
|
||||
|
||||
it('apply survives a missing logger (degrades, never throws)', () => {
|
||||
let effects = 0
|
||||
const ctx = {
|
||||
effect: (fn: () => void | (() => void)) => {
|
||||
effects += 1
|
||||
fn()
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { void route; return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
expect(() => host.apply(ctx as never)).not.toThrow()
|
||||
expect(effects).toBe(1)
|
||||
})
|
||||
|
||||
it('apply without an effect API still runs once synchronously', () => {
|
||||
const logs: string[] = []
|
||||
const ctx = { logger: { info: (msg: string) => logs.push(msg) } }
|
||||
host.apply(ctx as never)
|
||||
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes-sidebar invariant surface', () => {
|
||||
it('assert throws an InvariantError on a falsy condition', () => {
|
||||
expect(() => assert(false, 'boom')).toThrow(InvariantError)
|
||||
expect(() => assert(false, 'boom')).toThrow('boom')
|
||||
// A truthy condition passes silently.
|
||||
expect(() => assert(true, 'ok')).not.toThrow()
|
||||
expect(() => assert(1, 'ok')).not.toThrow()
|
||||
})
|
||||
|
||||
it('compareSemver orders versions (numeric + rc prerelease)', () => {
|
||||
expect(compareSemver('0.17.9', '0.18.0')).toBeLessThan(0)
|
||||
expect(compareSemver('0.18.0', '0.18.0')).toBe(0)
|
||||
expect(compareSemver('0.19.0', '0.18.0')).toBeGreaterThan(0)
|
||||
expect(compareSemver('1.0.0', '0.99.99')).toBeGreaterThan(0)
|
||||
// Prerelease sorts below the release it qualifies.
|
||||
expect(compareSemver('0.18.0-rc.1', '0.18.0')).toBeLessThan(0)
|
||||
expect(compareSemver('0.18.0-rc.1', '0.18.0-rc.2')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('assertBetterSidebarPeer warns on an old peer (no logger → no throw)', () => {
|
||||
const warnings: string[] = []
|
||||
const warn = (msg: string) => warnings.push(msg)
|
||||
// An old peer (0.17.x) triggers a warn.
|
||||
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.9' }
|
||||
assertBetterSidebarPeer({ warn })
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('0.17.9')
|
||||
// A new peer (0.18.x) does not.
|
||||
warnings.length = 0
|
||||
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.18.0' }
|
||||
assertBetterSidebarPeer({ warn })
|
||||
expect(warnings).toHaveLength(0)
|
||||
// No logger at all: still no throw.
|
||||
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.0' }
|
||||
expect(() => assertBetterSidebarPeer(undefined)).not.toThrow()
|
||||
delete (globalThis as Record<string, unknown>).__apesPeer__
|
||||
apply(ctx as never)
|
||||
// Default off: no tools are registered even though the settings service is mounted.
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers all eight tools.
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(8)
|
||||
expect(disposed).toBe(0)
|
||||
// Flipping it back off unregisters them (and releases any agent terminals).
|
||||
enabled = false
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(0)
|
||||
expect(disposed).toBe(8)
|
||||
// And a redundant toggle registers them fresh (no double-registration per
|
||||
// flip: the guard only skips when the tools are already live).
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Phase 1 tab-registration spec: mounts the CLIENT half against a mock
|
||||
* better-sidebar registry and verifies the five `apes:*` tabs register with
|
||||
* the right descriptors, the disposer tears them down (HMR-safe), and a
|
||||
* missing `betterSidebar` peer degrades without throwing.
|
||||
*
|
||||
* The React components are NOT rendered here (Phase 0 testing convention:
|
||||
* node vitest environment, no DOM). The descriptors' `component` field is
|
||||
* asserted to be a function; the JSX it returns is only evaluated when the
|
||||
* host actually mounts the tab, which a live DSH shell does (Phase 1+ mount
|
||||
* smoke). What this spec pins is the REGISTRATION CONTRACT: the ids, the
|
||||
* title/order/single shape, and the disposer lifecycle.
|
||||
*
|
||||
* The mock registry records every registered descriptor and returns a
|
||||
* disposer that removes it — mirroring the real better-sidebar
|
||||
* `registerTab` (returns `() => void`, invoked by cordis on fiber teardown).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply, filterTasksRows } from '../src/client/index.tsx'
|
||||
|
||||
/** The five tab ids this plugin owns (the + menu contract). */
|
||||
const EXPECTED_IDS = ['apes:tasks', 'apes:agents', 'apes:apps', 'apes:messages', 'apes:profile']
|
||||
|
||||
/** One registered descriptor shape the mock records (the fields we assert). */
|
||||
interface Registered {
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
order?: number
|
||||
single?: boolean
|
||||
component: unknown
|
||||
badge?: (ctx: unknown, scope: unknown, state: unknown) => string | number | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock better-sidebar registry: `registerTab` records the descriptor and
|
||||
* returns a disposer that removes it (the real service's contract).
|
||||
*/
|
||||
function makeMockRegistry() {
|
||||
const registered: Registered[] = []
|
||||
return {
|
||||
registry: {
|
||||
registerTab: (descriptor: Registered) => {
|
||||
registered.push(descriptor)
|
||||
return () => {
|
||||
const i = registered.findIndex((r) => r.id === descriptor.id)
|
||||
if (i !== -1) registered.splice(i, 1)
|
||||
}
|
||||
},
|
||||
getTabs: () => registered,
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
registered,
|
||||
}
|
||||
}
|
||||
|
||||
/** A no-op locale service (registerLocales only needs register/getSnapshot/subscribe). */
|
||||
function makeMockLocale() {
|
||||
return {
|
||||
getSnapshot: () => ({ active: 'zh' }),
|
||||
subscribe: () => () => undefined,
|
||||
register: () => () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a cordis effect: invoke the effect body, capture its returned
|
||||
* disposer, and expose `teardown()` so a test can simulate fiber unmount.
|
||||
*/
|
||||
function makeEffectHarness() {
|
||||
let disposer: (() => void) | undefined
|
||||
return {
|
||||
effect: (fn: () => void | (() => void)) => {
|
||||
const result = fn()
|
||||
if (typeof result === 'function') disposer = result
|
||||
return () => disposer?.()
|
||||
},
|
||||
teardown: () => disposer?.(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('apes-sidebar client tab registration (Phase 1)', () => {
|
||||
it('registers all five apes:* tabs into the better-sidebar registry', () => {
|
||||
const { registry, registered } = makeMockRegistry()
|
||||
const eff = makeEffectHarness()
|
||||
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
|
||||
|
||||
expect(registered.map((r) => r.id).sort()).toEqual([...EXPECTED_IDS].sort())
|
||||
expect(registry.getTabs()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('gives every tab a title function, an ascending order, single-instance, and a component fn', () => {
|
||||
const { registered } = makeMockRegistry()
|
||||
const eff = makeEffectHarness()
|
||||
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
|
||||
// Re-run on the registry whose descriptors we captured.
|
||||
const { registry, registered: reg2 } = makeMockRegistry()
|
||||
const eff2 = makeEffectHarness()
|
||||
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff2 } as never)
|
||||
|
||||
for (const d of reg2) {
|
||||
expect(EXPECTED_IDS).toContain(d.id)
|
||||
// title is a function (i18n-friendly) and resolves to a non-empty string.
|
||||
expect(typeof d.title === 'function' ? typeof d.title() : typeof d.title).toBe('string')
|
||||
if (typeof d.title === 'function') expect(d.title().length).toBeGreaterThan(0)
|
||||
// order is present and numeric.
|
||||
expect(typeof d.order).toBe('number')
|
||||
// single-instance sugar (each apes:* tab is a singleton panel).
|
||||
expect(d.single).toBe(true)
|
||||
// component is a render function.
|
||||
expect(typeof d.component).toBe('function')
|
||||
}
|
||||
// orders are strictly ascending in registration order.
|
||||
const orders = reg2.map((d) => d.order as number)
|
||||
for (let i = 1; i < orders.length; i++) {
|
||||
expect(orders[i]!).toBeGreaterThan(orders[i - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('the combined disposer removes all five tabs (HMR-safe teardown)', () => {
|
||||
const { registry } = makeMockRegistry()
|
||||
const eff = makeEffectHarness()
|
||||
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
|
||||
expect(registry.getTabs()).toHaveLength(5)
|
||||
|
||||
// Simulate fiber unmount: cordis invokes the effect's returned disposer.
|
||||
eff.teardown()
|
||||
expect(registry.getTabs()).toHaveLength(0)
|
||||
|
||||
// A second teardown is a safe no-op (double-dispose is swallowed).
|
||||
expect(() => eff.teardown()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not register when betterSidebar is absent (bare DSH degrades)', () => {
|
||||
const eff = makeEffectHarness()
|
||||
expect(() => apply({ locale: makeMockLocale(), ...eff } as never)).not.toThrow()
|
||||
// No effect was run (the guard returns before ctx.effect).
|
||||
// The effect harness captured nothing.
|
||||
expect(eff.effect).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('registers synchronously when no effect API is present (non-cordis harness)', () => {
|
||||
const { registry } = makeMockRegistry()
|
||||
// No `effect` on the context → apply registers synchronously.
|
||||
apply({ betterSidebar: registry, locale: makeMockLocale() } as never)
|
||||
expect(registry.getTabs()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('tab ids follow the apes:* namespace (never wb:* or builtin ids)', () => {
|
||||
const { registered } = makeMockRegistry()
|
||||
const eff = makeEffectHarness()
|
||||
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
|
||||
for (const d of registered) {
|
||||
expect(d.id.startsWith('apes:')).toBe(true)
|
||||
expect(d.id).not.toMatch(/^wb:/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a mock `ctx.sessions.list.getSnapshot()` feed for badge assertions.
|
||||
* `byId` maps session id → a summary with optional `running` / `origin`.
|
||||
*/
|
||||
function makeMockSessionsCtx(byId: Record<string, { running?: boolean; origin?: string }>) {
|
||||
const snapshot = { current: undefined as string | undefined, byId }
|
||||
return {
|
||||
sessions: {
|
||||
list: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 badge contract: `apes:tasks` shows the running-session count,
|
||||
* `apes:agents` shows the subagent count, and BOTH hide (return null) when
|
||||
* their count is 0. The badge is a pure `(ctx) => …` function, so it is
|
||||
* exercised directly against a mock sessions feed — no DOM, no rendering.
|
||||
*/
|
||||
describe('apes-sidebar tab badges (Phase 2)', () => {
|
||||
/** Register into a fresh mock registry and return the captured descriptors. */
|
||||
function registeredDescriptors() {
|
||||
const { registry, registered } = makeMockRegistry()
|
||||
const eff = makeEffectHarness()
|
||||
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
|
||||
return registered
|
||||
}
|
||||
|
||||
function badgeOf(registered: Registered[], id: string) {
|
||||
return registered.find((d) => d.id === id)?.badge
|
||||
}
|
||||
|
||||
it('apes:tasks badge is the running-session count (2 running → 2)', () => {
|
||||
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
|
||||
expect(badge).toBeTypeOf('function')
|
||||
const ctx = makeMockSessionsCtx({
|
||||
a: { running: true },
|
||||
b: { running: true },
|
||||
c: { running: false },
|
||||
})
|
||||
expect(badge!(ctx as never, {} as never, {} as never)).toBe(2)
|
||||
})
|
||||
|
||||
it('apes:tasks badge is null when nothing is running (hidden pill)', () => {
|
||||
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
|
||||
const ctx = makeMockSessionsCtx({ a: { running: false }, b: {} })
|
||||
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('apes:agents badge is the subagent count (1 subagent → 1)', () => {
|
||||
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
|
||||
expect(badge).toBeTypeOf('function')
|
||||
const ctx = makeMockSessionsCtx({
|
||||
parent: {},
|
||||
child: { origin: 'subagent' },
|
||||
other: { running: true }, // running but NOT a subagent → not counted
|
||||
})
|
||||
expect(badge!(ctx as never, {} as never, {} as never)).toBe(1)
|
||||
})
|
||||
|
||||
it('apes:agents badge is null when there are no subagents (hidden pill)', () => {
|
||||
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
|
||||
const ctx = makeMockSessionsCtx({ a: {}, b: { running: true } })
|
||||
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('apps/messages/profile have NO badge (information placeholders, no count)', () => {
|
||||
const registered = registeredDescriptors()
|
||||
for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) {
|
||||
expect(badgeOf(registered, id)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Phase 2 per-tab state: the apes:tasks `showInactive` filter. When the user
|
||||
* toggles the preference off, only running sessions render. This is a pure
|
||||
* decision function (the hook's uSES↔settings wiring needs a render, but the
|
||||
* filter itself is plain and unit-testable).
|
||||
*/
|
||||
describe('apes:tasks showInactive per-tab state filter (Phase 2)', () => {
|
||||
const sessions = [
|
||||
{ id: 'a', running: true },
|
||||
{ id: 'b', running: false },
|
||||
{ id: 'c', running: true },
|
||||
{ id: 'd' }, // running undefined → treated as not running
|
||||
]
|
||||
|
||||
it('showInactive=true renders every session', () => {
|
||||
expect(filterTasksRows(sessions, true)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('showInactive=false renders only running sessions (2 of 4)', () => {
|
||||
const rows = filterTasksRows(sessions, false)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.map((s) => s.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('showInactive=false on an all-running list keeps everything', () => {
|
||||
expect(filterTasksRows([{ running: true }, { running: true }], false)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('showInactive=false on an all-inactive list yields empty', () => {
|
||||
expect(filterTasksRows([{ running: false }, {}], false)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Tool-layer tests for the eight agent-terminal tools: registration shape,
|
||||
* the combined disposer, canonical-output contract (validated against each
|
||||
* tool's declared output schema exactly like the registry's
|
||||
* `createSuccessResult` does), per-session ownership, and the UTF-8
|
||||
* truncation boundary. The pty registry is stubbed (no real spawn): the
|
||||
* wiring of the tools themselves is the subject here — pty behavior is
|
||||
* covered by agent-pty.spec.ts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { boundBytes, registerTools } from '../src/tools.ts'
|
||||
import type { AgentPtyRegistry } from '../src/agent-pty.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** One registration captured by the fake tools service. */
|
||||
interface CapturedTool {
|
||||
definition: ToolDefinition
|
||||
}
|
||||
|
||||
/** Stub registry: uuid-keyed rows without a real pty. */
|
||||
class FakeRegistry {
|
||||
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
|
||||
|
||||
create(sessionId: string, title: string, command: string): string {
|
||||
const uuid = `uuid-${this.terminals.size + 1}`
|
||||
this.terminals.set(uuid, { sessionId, title, command, exited: false })
|
||||
return uuid
|
||||
}
|
||||
|
||||
list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> {
|
||||
return [...this.terminals.entries()]
|
||||
.filter(([, row]) => row.sessionId === sessionId)
|
||||
.map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited }))
|
||||
}
|
||||
|
||||
assertOwned(uuid: string, sessionId: string): void {
|
||||
const row = this.terminals.get(uuid)
|
||||
if (row === undefined || row.sessionId !== sessionId) {
|
||||
throw new Error(`agent terminal "${uuid}" not found`)
|
||||
}
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
|
||||
read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } {
|
||||
// A page larger than the tool's 256 KiB read cap, to exercise boundBytes.
|
||||
return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 }
|
||||
}
|
||||
|
||||
resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
return { cols, rows }
|
||||
}
|
||||
|
||||
signal(): void {}
|
||||
|
||||
close(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> {
|
||||
// Mirrors the registry's empty-needle rejection (the tool layer no
|
||||
// longer duplicates this check).
|
||||
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
|
||||
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */
|
||||
function exec(sessionId: string): ToolRunContext {
|
||||
return {
|
||||
signal: { throwIfAborted: () => {}, aborted: false },
|
||||
agent: { session: { id: sessionId } },
|
||||
} as unknown as ToolRunContext
|
||||
}
|
||||
|
||||
/** Register the eight tools against a fake service; the tools bind to `registry`. */
|
||||
function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } {
|
||||
const captured: CapturedTool[] = []
|
||||
let disposeCount = 0
|
||||
const ctx = {
|
||||
tools: {
|
||||
register: (tool: unknown): (() => void) => {
|
||||
captured.push({ definition: tool as ToolDefinition })
|
||||
return () => { disposeCount += 1 }
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
const registry = new FakeRegistry()
|
||||
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd')
|
||||
return { captured, registry, dispose: () => { dispose(); return disposeCount } }
|
||||
}
|
||||
|
||||
/** The captured definition for one tool name. */
|
||||
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
|
||||
const found = captured.find(candidate => candidate.definition.name === name)
|
||||
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
|
||||
return found.definition
|
||||
}
|
||||
|
||||
describe('agent terminal tools', () => {
|
||||
it('registers all eight tools and the combined disposer unregisters them all', () => {
|
||||
const { captured, dispose } = mount()
|
||||
expect(captured.map(candidate => candidate.definition.name)).toEqual([
|
||||
'terminal_create',
|
||||
'terminal_list',
|
||||
'terminal_send',
|
||||
'terminal_read',
|
||||
'terminal_wait_for',
|
||||
'terminal_resize',
|
||||
'terminal_signal',
|
||||
'terminal_close',
|
||||
])
|
||||
expect(dispose()).toBe(8)
|
||||
// Disposing twice must not throw (each registry disposer is idempotent).
|
||||
expect(() => mount().dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('terminal_create returns the uuid and echoed title (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_create')
|
||||
const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1'))
|
||||
expect(value).toMatchObject({ title: 'dev server' })
|
||||
expect(typeof (value as { uuid: unknown }).uuid).toBe('string')
|
||||
expect(registry.terminals.size).toBe(1)
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_list output matches the declared schema — no sessionId leaks', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_list')
|
||||
const uuid = registry.create('s1', 'dev server', 'npm run dev')
|
||||
registry.create('s2', 'other session', 'top secret')
|
||||
const value = await tool.execute({}, exec('s1'))
|
||||
const list = value as Array<Record<string, unknown>>
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]!.uuid).toBe(uuid)
|
||||
// The session id is registry-internal ownership, never serialized.
|
||||
expect('sessionId' in list[0]!).toBe(false)
|
||||
// And the whole value validates against the declared output schema —
|
||||
// this is the contract the registry enforces at runtime (additionalProperties: false).
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const mine = registry.create('s1', 'mine', '')
|
||||
const foreign = registry.create('s2', 'theirs', '')
|
||||
for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) {
|
||||
const tool = toolOf(captured, name)
|
||||
const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' }
|
||||
: name === 'terminal_read' ? { uuid: foreign }
|
||||
: name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 }
|
||||
: name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' }
|
||||
: name === 'terminal_close' ? { uuid: foreign }
|
||||
: { uuid: foreign, needle: 'x', timeout_ms: 100 }
|
||||
// A foreign uuid is indistinguishable from an unknown one.
|
||||
await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/)
|
||||
}
|
||||
// Own-session uuids still work.
|
||||
const sendTool = toolOf(captured, 'terminal_send')
|
||||
await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined()
|
||||
const closeTool = toolOf(captured, 'terminal_close')
|
||||
await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true })
|
||||
})
|
||||
|
||||
it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_read')
|
||||
const uuid = registry.create('s1', 'reader', '')
|
||||
const value = await tool.execute({ uuid }, exec('s1'))
|
||||
const result = value as { text: string; truncated: boolean }
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_resize')
|
||||
const uuid = registry.create('s1', 'resizer', '')
|
||||
const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1'))
|
||||
expect(value).toEqual({ uuid, cols: 120, rows: 40 })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
const uuid = registry.create('s1', 'waiter', '')
|
||||
const value = await tool.execute({ uuid, needle: 'done' }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
const uuid = registry.create('s1', 'waiter', '')
|
||||
await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundBytes', () => {
|
||||
it('never splits a multi-byte UTF-8 sequence at the cap', () => {
|
||||
// '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary.
|
||||
expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true })
|
||||
expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true })
|
||||
expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true })
|
||||
expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false })
|
||||
expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true })
|
||||
// ASCII pages truncate at the byte cap directly.
|
||||
expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true })
|
||||
})
|
||||
|
||||
it('every retained prefix decodes without U+FFFD', () => {
|
||||
for (let n = 1; n <= 18; n += 1) {
|
||||
const { text } = boundBytes('中文字符串测试 123', n)
|
||||
expect(text.includes('\uFFFD')).toBe(false)
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n)
|
||||
}
|
||||
})
|
||||
})
|
||||
+1105
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src. The typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS. Consumers resolve declarations through the package exports' types subpaths.",
|
||||
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src (the typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS). Consumers resolve declarations through the package exports' types subpaths.",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
|
||||
+2
-2
@@ -4,8 +4,6 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
@@ -13,6 +11,8 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
|
||||
+146
-92
@@ -1,33 +1,66 @@
|
||||
/**
|
||||
* tsdown build for apes-sidebar:
|
||||
* - host-half lib (lib/index.js + lib/invariant.js, ESM node)
|
||||
* - the browser client bundle (lib/client.js, CJS closure factory) registered
|
||||
* under the package-name id `apes-sidebar` (official profile channel).
|
||||
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
|
||||
* the lib/invariant.js companion, ESM node) plus the two browser client
|
||||
* bundles (lib/client.js and lib/client-registry.js, CJS closure factory) —
|
||||
* one per install channel:
|
||||
*
|
||||
* The client bundle replicates the official DSH client-bundle preset
|
||||
* (packages/client/tsdown.client.ts): externals resolve through the loader
|
||||
* module table at runtime; everything else inlines; the purity gate rejects
|
||||
* any other @deepseek-ai value import (cross-plugin collaboration goes
|
||||
* through cordis services, never value imports); CSS compiles to hashed
|
||||
* class maps and injects <style data-plugin> tags at factory execution;
|
||||
* the artifact registers itself via window.__ModuleLoader__.load.
|
||||
* - `lib/client.js` serves the official profile channel, registering with
|
||||
* the package-name id `dsh-better-sidebar` (the client-modules compose
|
||||
* keys on the package name; keep it in sync with package.json `name`),
|
||||
* - `lib/client-registry.js` serves the plugin-registry channel
|
||||
* (dsh.plugin.json), registering with the manifest id
|
||||
* `dsh-external/dsh-better-sidebar` (the registry browser-side `arrive()`
|
||||
* check requires bundle id === plugin id).
|
||||
*
|
||||
* Both bundles replicate the official DSH client-bundle preset
|
||||
* (packages/client/tsdown.client.ts) and are compiled from the same
|
||||
* src/client/index.tsx source — only the registered id and the output file
|
||||
* name differ, so they cannot drift:
|
||||
* - externals resolve through the loader module table at runtime (the
|
||||
* PLATFORM_MODULES seed list from apps/web's platform.ts, plus the
|
||||
* runtime/client exemption),
|
||||
* - everything else is inlined into the bundle (xterm, clsx, ...),
|
||||
* - the purity gate rejects any other @deepseek-ai value import: cross-plugin
|
||||
* collaboration goes through cordis services, never value imports,
|
||||
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
|
||||
* tags at factory execution,
|
||||
* - each artifact registers itself via window.__ModuleLoader__.load({id,
|
||||
* factory}) with the (require) => exports CJS closure shape.
|
||||
*
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules } from 'node:module'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules, createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
/** Node builtins must never survive into the browser module-loader factory. */
|
||||
const NODE_BUILTINS = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map(id => `node:${id}`),
|
||||
])
|
||||
|
||||
/** Module specifiers the web shell shares into the frozen module table. */
|
||||
/**
|
||||
* Browser-only standalone entries for dependencies whose package `browser`
|
||||
* remaps Rolldown does not currently honor after CJS lowering. Without these
|
||||
* aliases nanoid/SheetJS/JSZip leave Node builtin require() calls in the
|
||||
* client factory, which the DSH module table correctly refuses.
|
||||
*/
|
||||
const DOCX_PREVIEW_ENTRY = require.resolve('docx-preview')
|
||||
const JSZIP_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('jszip/package.json', { paths: [dirname(DOCX_PREVIEW_ENTRY)] })),
|
||||
'dist/jszip.min.js',
|
||||
)
|
||||
const XLSX_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('xlsx/package.json')),
|
||||
'dist/xlsx.full.min.js',
|
||||
)
|
||||
|
||||
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
||||
const CLIENT_EXTERNALS = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
@@ -35,18 +68,22 @@ const CLIENT_EXTERNALS = [
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline (mirror of the official
|
||||
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
|
||||
* to share.
|
||||
* to share. Everything else under @deepseek-ai/* is either a module-table
|
||||
* entry (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
|
||||
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
|
||||
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
|
||||
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
|
||||
@@ -77,8 +114,10 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
|
||||
/**
|
||||
* One client bundle build for a plugin id. The same src/client/index.tsx is
|
||||
* compiled once per install channel; only the registered id and the output
|
||||
* file name differ, so they cannot drift.
|
||||
* compiled twice with only the registered id and the output file name
|
||||
* differing: the official channel uses the package name (`dsh-better-sidebar`)
|
||||
* and the registry channel uses the manifest id
|
||||
* (`dsh-external/dsh-better-sidebar`).
|
||||
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
|
||||
* data-plugin style-tag prefix of this bundle.
|
||||
* @param entryFile - the output file name under lib/.
|
||||
@@ -92,101 +131,111 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
dts: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
deps: {
|
||||
neverBundle: [...CLIENT_EXTERNALS],
|
||||
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
|
||||
},
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
// pptx-renderer only uses this to auto-discover its optional PDF.js
|
||||
// fallback. PptxView passes pdfjs:false, so browser CJS has no resolver.
|
||||
'import.meta.resolve': 'undefined',
|
||||
},
|
||||
alias: {
|
||||
jszip: JSZIP_BROWSER_ENTRY,
|
||||
xlsx: XLSX_BROWSER_ENTRY,
|
||||
},
|
||||
// CJS output otherwise makes some transitive packages (notably nanoid
|
||||
// through Univer Core) resolve their Node entry even though this bundle
|
||||
// runs in the browser. Keep browser conditional exports authoritative for
|
||||
// both source import() and generated require() edges.
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
},
|
||||
},
|
||||
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
|
||||
// External wins for module-table entries; every other dependency inlines.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [{
|
||||
// Bundle purity gate (mirror of the official preset): platform seed
|
||||
// entries stay external, inline-safe wire layers inline, and every
|
||||
// other @deepseek-ai value import is a build error — a cross-plugin
|
||||
// value import either inlines a duplicate runtime instance or requires
|
||||
// a specifier the frozen module table cannot answer. Cross-plugin
|
||||
// collaboration goes through cordis services instead. Type-only
|
||||
// imports are erased and never reach this gate.
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (NODE_BUILTINS.has(source)) {
|
||||
throw new Error(
|
||||
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||
)
|
||||
}
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
// Relative/absolute paths resolve against the importer; bare
|
||||
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
|
||||
let abs: string
|
||||
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
||||
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
||||
} else {
|
||||
abs = require.resolve(source)
|
||||
}
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
// CSS Modules (x.module.css) become hashed class maps; plain css
|
||||
// (xterm's stylesheet) is inlined verbatim.
|
||||
if (fileId.endsWith('.module.css')) {
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
return [
|
||||
injectTag(pluginId, fileId, code.toString()),
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
injectTag(pluginId, fileId, source.toString('utf8')),
|
||||
'export default "";',
|
||||
].join('\n')
|
||||
},
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: entryFile,
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
// The CJS wrapper factory's `require` only resolves module-table entries
|
||||
// (react, cordis, ...); it cannot load relative chunk URLs in the browser.
|
||||
// Disable code splitting so dynamic import() inlines into the single
|
||||
// factory chunk (Office preview libs — docx-preview, Univer — pull in
|
||||
// several MB but only when first opened, the trade-off for wrapper safety).
|
||||
codeSplitting: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
|
||||
type BuildPlugin = NonNullable<UserConfig['plugins']>
|
||||
|
||||
/** The shared client-bundle purity gate. */
|
||||
function purityGatePlugin(): BuildPlugin {
|
||||
return {
|
||||
name: 'apes-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (NODE_BUILTINS.has(source)) {
|
||||
throw new Error(
|
||||
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||
)
|
||||
}
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
|
||||
function makeCssPlugin(pluginId: string): BuildPlugin {
|
||||
return {
|
||||
name: 'apes-css-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
let abs: string
|
||||
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
||||
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
||||
} else {
|
||||
// Bare specifiers resolve from the package.
|
||||
const { createRequire } = require('node:module')
|
||||
abs = createRequire(import.meta.url).resolve(source)
|
||||
}
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
if (fileId.endsWith('.module.css')) {
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
return [
|
||||
injectTag(pluginId, fileId, code.toString()),
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
injectTag(pluginId, fileId, source.toString('utf8')),
|
||||
'export default "";',
|
||||
].join('\n')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
|
||||
@@ -196,8 +245,13 @@ export default [
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
// clean stays off: the build script removes lib/ wholesale before tsc, so
|
||||
// a tsdown clean here would wipe the lib/types declarations tsc just
|
||||
// emitted (and `watch` must never touch them).
|
||||
clean: false,
|
||||
},
|
||||
// Official profile channel: bundle id = package name (package.json `name`).
|
||||
clientBundle('apes-sidebar', 'client.js'),
|
||||
clientBundle('dsh-better-sidebar', 'client.js'),
|
||||
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
|
||||
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
|
||||
] satisfies UserConfig[]
|
||||
|
||||
+9
-13
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* Vitest config: inline the npm-published `@deepseek-ai/*` packages whose
|
||||
* BUILT lib bundles css side-effect imports. Installed from the npm registry
|
||||
* these packages live under `node_modules/.pnpm` and are externalized by
|
||||
* vitest — Node then chokes on the `.css` import. Inlining routes them
|
||||
* through Vite's transform, which stubs css imports (the default `css: false`).
|
||||
* BUILT lib bundles css side-effect imports (e.g. `dsh-client-ui-primitives`
|
||||
* imports `katex/dist/katex.min.css` at the top of its `lib/index.js`).
|
||||
*
|
||||
* Installed from the npm registry (the default since v0.4.1) these packages
|
||||
* live under `node_modules/.pnpm` and are externalized by vitest — Node then
|
||||
* chokes on the `.css` import. Inlining routes them through Vite's transform,
|
||||
* which stubs css imports (the default `css: false`). The previous
|
||||
* `link:`-to-source-checkout install needed no such config: linked files sit
|
||||
* outside `node_modules` and are transformed by default.
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
@@ -14,14 +19,5 @@ export default defineConfig({
|
||||
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
|
||||
},
|
||||
},
|
||||
exclude: [
|
||||
'tests/e2e/**',
|
||||
'**/.worktrees/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user