Merge pull request #3 from dsh-external/feat/service-registry
feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer)+ 内置迁移到 registry,v0.4.0
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
# dsh-better-sidebar 插件接入文档
|
||||
|
||||
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
|
||||
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务定位
|
||||
|
||||
- **服务名**:`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'` 即触发类型合并
|
||||
|
||||
> ⚠️ **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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `dsh-better-sidebar` 必须声明为 **peerDependency**(不是 dependency,避免重复实例化)
|
||||
- 标记 `optional: true` 让你的插件在 better-sidebar 未安装时也能加载(注册代码会因为 `ctx.betterSidebar` 为 undefined 而跳过)
|
||||
|
||||
### 2.2 client half 入口
|
||||
|
||||
```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} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。
|
||||
|
||||
### 2.3 类型导入
|
||||
|
||||
```ts
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service` exports 子路径暴露。
|
||||
|
||||
---
|
||||
|
||||
## 3. Tab 注册 API
|
||||
|
||||
### 3.1 `TabDescriptor` 完整字段
|
||||
|
||||
```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 配额满) */
|
||||
available?: (state: SidebarState) => boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开)。
|
||||
* 内置策略:explorer/git/subagent 用 () => id(单实例);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
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `TabComponentProps`
|
||||
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce/openTab 等)
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 注册示例
|
||||
|
||||
**最简 tab**(单实例、+ 菜单可见):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:notes', // 单实例
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**多实例 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' })
|
||||
```
|
||||
|
||||
**条件可见**(仅 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} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 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
|
||||
/** 小写无点的扩展名数组:['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>
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
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?)` 按以下顺序解析:
|
||||
|
||||
1. **priority 降序**(稳定排序,相同 priority 按注册顺序)
|
||||
2. **detect 嗅探**:若 `head` 字节可用,遍历 priority 降序的 viewer,第一个 `detect(path, head)` 返回 true 的命中(**覆盖 exts**)
|
||||
3. **exts 匹配**:第一个 `exts` 含目标扩展名的 viewer 命中(`exts: []` 跳过此轮)
|
||||
4. **catch-all**:第一个 `exts: []` 的 viewer 命中
|
||||
5. **fallback**:返回 `undefined`,EditorView 走 CodeMirror 兜底(按扩展名选语法高亮)
|
||||
|
||||
> **内置 viewer**(不可重复注册):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / binary-download(-50)。
|
||||
> code/markdown **没有**注册为 viewer,是 EditorView 的兜底逻辑(matchFileViewer 返回 undefined 时走 fsRead + CodeMirror/MarkdownText)。外部 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 描述符快照 */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 按 path 匹配 file viewer(priority desc → detect → exts) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重) */
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.tsx`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 6 个内置 viewer(image/pdf/docx/xlsx/pptx/binary-download)的注册代码
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`tests/service.spec.ts`**:12 个测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
@@ -286,6 +286,30 @@ pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
|
||||
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
|
||||
|
||||
## 🔌 服务化(v0.4.0+):让其他插件注册 tab 和文件预览器
|
||||
|
||||
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件可通过它注册:
|
||||
|
||||
- **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })`
|
||||
- **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })`
|
||||
|
||||
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 6 个 viewer(image/pdf/docx/xlsx/pptx/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。
|
||||
|
||||
**消费插件最小骨架**:
|
||||
```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} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
**完整接入文档**:见 [`AGENTS.md`](./AGENTS.md) ——含 `TabDescriptor` / `FileViewerDescriptor` 全字段、`fetchStrategy` 对照、匹配算法、HMR 陷阱、完整最小示例。
|
||||
|
||||
## 🔐 安全边界
|
||||
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# 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 生成。
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.3.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
|
||||
"version": "0.4.0",
|
||||
"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",
|
||||
@@ -19,6 +19,10 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
||||
+53
-52
@@ -7,23 +7,20 @@
|
||||
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
|
||||
* (a banner marks truncation).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createElement, 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 type { Context } from '../context-types.ts'
|
||||
import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { officeKindForExt } from './office-types.ts'
|
||||
import { DocxView, XlsxView } from './office-view.tsx'
|
||||
import { isPdfExt } from './pdf-types.ts'
|
||||
import { PdfView } from './PdfView.tsx'
|
||||
import { PptxView } from './PptxView.tsx'
|
||||
import { isImageExt } from './image-types.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
const MD_EXT = ['.md', '.markdown']
|
||||
@@ -31,14 +28,14 @@ const MD_EXT = ['.md', '.markdown']
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
|
||||
| { status: 'ready'; viewer?: FileViewerDescriptor; kind?: 'text' | 'md'; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
export function EditorView(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' })
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
@@ -62,30 +59,44 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
// Office preview short-circuit: the host's readText would NUL-probe every
|
||||
// Office file as binary (a wasted round-trip), and .docx/.xlsx have their
|
||||
// own renderers below. Detect by extension and skip fsRead entirely —
|
||||
// the renderer fetches bytes through the media route itself.
|
||||
const ext = extOfPath(path)
|
||||
// Images are binary by nature; short-circuit before fsRead's NUL probe or
|
||||
// every PNG/JPEG would be classified as an unsupported binary file.
|
||||
if (isImageExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (isPdfExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (officeKind === 'download-only') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
// Registry-driven viewer match: image/pdf/office/binary-download etc.
|
||||
// are registered through ctx.betterSidebar; code/markdown have no
|
||||
// registered viewer and fall through to the fsRead + CodeMirror path.
|
||||
const viewer = ctx.betterSidebar?.matchFileViewer(path)
|
||||
if (viewer !== undefined) {
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl':
|
||||
case 'none':
|
||||
setLoad({ status: 'ready', viewer, mediaUrl: mediaUrl(scope, path) })
|
||||
return
|
||||
case 'binary-download':
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
case 'custom':
|
||||
void viewer.load?.(path, scope).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
case 'fsRead':
|
||||
// An fsRead viewer (e.g. external CSV viewer) gets its content
|
||||
// through the host's fs.read and renders through viewer.component.
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') { setLoad({ status: 'binary' }); return }
|
||||
setLoad({ status: 'ready', viewer, content: result.content, truncated: result.truncated })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: code/markdown (no registered viewer) — fsRead + CodeMirror.
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') {
|
||||
@@ -103,7 +114,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
|
||||
// Create the CodeMirror editor once the file is loaded. The view owns the
|
||||
// document; React only tracks dirty/draft state through the update
|
||||
@@ -112,7 +123,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
|
||||
if (load.status !== 'ready' || load.viewer !== undefined || (load.kind !== 'text' && load.kind !== 'md')) return
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const language = languageForPath(path)
|
||||
@@ -248,26 +259,16 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'image' && (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
{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} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pptx' && (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pdf' && (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
{previewable && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content} />
|
||||
<MarkdownText text={draft ?? load.content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</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 style={{ display: 'block', marginTop: 8, fontSize: 12, opacity: 0.7 }}>{tab.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+41
-101
@@ -15,7 +15,7 @@ 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, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab,
|
||||
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
@@ -23,18 +23,11 @@ import {
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
import { GitView } from './GitView.tsx'
|
||||
import { DiffTab } from './DiffTab.tsx'
|
||||
import { SubagentView } from './SubagentView.tsx'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
@@ -60,64 +53,31 @@ function TabContent(props: {
|
||||
}) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
switch (tab.type) {
|
||||
case 'explorer':
|
||||
return (
|
||||
<ExplorerView
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
)
|
||||
case 'git':
|
||||
return (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff}
|
||||
/>
|
||||
)
|
||||
case 'diff':
|
||||
return tab.diff === undefined
|
||||
? null
|
||||
: <DiffTab sessionId={sessionId} cwd={cwd} diff={tab.diff} />
|
||||
case 'terminal':
|
||||
return <TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
case 'editor':
|
||||
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
case 'subagent':
|
||||
return (
|
||||
<SubagentView
|
||||
sessionId={sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump(address.childSessionId) }}
|
||||
/>
|
||||
)
|
||||
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 (UI-tab terminal cap applied).
|
||||
* Agent-owned terminals (`agent:` tabs) are the model's, not the user's:
|
||||
* they never count toward the per-session UI cap. */
|
||||
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
||||
const uiTerminalCount = allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
return [
|
||||
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
|
||||
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
|
||||
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
|
||||
{
|
||||
id: 'terminal',
|
||||
label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
disabled: uiTerminalCount >= TERMINAL_LIMIT,
|
||||
icon: tabTypeIcon('terminal', 16),
|
||||
},
|
||||
]
|
||||
/** 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. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden)
|
||||
.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?.(state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
@@ -225,12 +185,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionList, sessionId, store])
|
||||
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
|
||||
@@ -248,12 +205,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionId, store])
|
||||
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 })
|
||||
@@ -367,30 +321,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
|
||||
const onNewTab = (optionId: string): void => {
|
||||
store.reduce((s) => {
|
||||
if (optionId === 'explorer') {
|
||||
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
|
||||
}
|
||||
if (optionId === 'git') {
|
||||
return openTab(s, { id: 'git', type: 'git', title: t('git') })
|
||||
}
|
||||
if (optionId === 'subagent') {
|
||||
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
|
||||
}
|
||||
if (optionId === 'terminal') {
|
||||
const count = allLeaves(s.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
if (count >= TERMINAL_LIMIT) return s
|
||||
const tab: SidebarTab = {
|
||||
id: `terminal:${s.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${s.nextTerminal}`,
|
||||
}
|
||||
return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 }
|
||||
}
|
||||
return s
|
||||
})
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,10 +433,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state)}
|
||||
newTabOptions={buildNewTabOptions(state, ctx)}
|
||||
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>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -62,9 +61,11 @@ export function TabBar(props: {
|
||||
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,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
@@ -134,7 +135,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tabTypeIcon(tab.type, 14)}
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Built-in tab types and file viewers: the plugin registers its own
|
||||
* built-in pages (explorer / git / terminal / subagent / editor / diff)
|
||||
* and file previewers (image / pdf / docx / xlsx / pptx / binary-download)
|
||||
* through the same {@link BetterSidebarService} external plugins use —
|
||||
* eating its own dogfood. Code/Markdown editing stays inlined in
|
||||
* {@link EditorView} as the catch-all fallback (no matching viewer →
|
||||
* fsRead + CodeMirror/MarkdownText).
|
||||
*/
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { TERMINAL_LIMIT, allLeaves, isAgentTabId, type SidebarState } from './state.ts'
|
||||
import { downloadUrl, mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
import { GitView } from './GitView.tsx'
|
||||
import { DiffTab } from './DiffTab.tsx'
|
||||
import { SubagentView } from './SubagentView.tsx'
|
||||
import { DocxView, XlsxView } from './office-view.tsx'
|
||||
import { PdfView } from './PdfView.tsx'
|
||||
import { PptxView } from './PptxView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx'
|
||||
import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** 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. */
|
||||
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 }) => (
|
||||
<EditorView 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,
|
||||
dedupeKey: () => 'explorer',
|
||||
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,
|
||||
dedupeKey: () => 'git',
|
||||
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,
|
||||
dedupeKey: () => 'subagent',
|
||||
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: (state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
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} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */
|
||||
const builtinViewers: readonly FileViewerDescriptor[] = [
|
||||
{
|
||||
id: 'image',
|
||||
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',
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
component: ({ scope, path }) => (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (EditorView 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,6 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode {
|
||||
case 'subagent': return <IconThinkOutline16 size={size} />
|
||||
case 'editor': return <IconCodeOutline16 size={size} />
|
||||
case 'diff': return <IconDiffOutline16 size={size} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
@@ -69,6 +71,19 @@ export function apply(ctx: Context): void {
|
||||
// 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.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { openTab, type SidebarStore } from './state.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'
|
||||
@@ -19,12 +19,9 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
|
||||
const absolute = resolveSidebarPath(summary?.cwd, path)
|
||||
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||
store.reduce(state => openTab(state, {
|
||||
id: `editor:${absolute}`,
|
||||
type: 'editor',
|
||||
title,
|
||||
path: absolute,
|
||||
}))
|
||||
// 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). */
|
||||
|
||||
@@ -87,6 +87,7 @@ const zh = {
|
||||
disconnected: '终端连接断开,重连中…',
|
||||
exited: '终端进程已退出',
|
||||
noSession: '选择一个会话以使用侧边栏',
|
||||
pluginNotLoaded: '插件未加载,标签页暂不可用:',
|
||||
hiddenFiles: '隐藏文件',
|
||||
parent: '上级目录',
|
||||
copied: '已复制',
|
||||
@@ -219,6 +220,7 @@ const en: Record<keyof typeof zh, string> = {
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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 ../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).
|
||||
* - `createTab` lets a descriptor own tab instantiation (the terminal
|
||||
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
|
||||
* - `matchFileViewer` resolves viewers by priority (desc), then `detect`
|
||||
* (content sniff), then `exts` (lowercase, no dot).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** 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). */
|
||||
available?: (state: SidebarState) => 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: explorer/git/subagent use `() => id`; 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
|
||||
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
|
||||
/** 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
|
||||
/** 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 (overrides exts when `head` bytes are available).
|
||||
* The first descriptor (priority desc) whose `detect` returns true wins.
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
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
|
||||
/** Find a file viewer for a path (priority desc → detect → exts). */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** Open a tab (used by external tabs and the + menu). */
|
||||
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)
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Priority descending; stable order for equal priorities (insertion order).
|
||||
const sorted = Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)
|
||||
// Pass 1: content sniff (detect overrides exts when head bytes are available).
|
||||
if (head !== undefined) {
|
||||
for (const v of sorted) {
|
||||
if (v.detect !== undefined && v.detect(path, head)) return v
|
||||
}
|
||||
}
|
||||
// Pass 2: extension match ([] is catch-all, skipped here).
|
||||
for (const v of sorted) {
|
||||
if (v.exts.length === 0) continue
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
// Pass 3: catch-all (exts === []).
|
||||
for (const v of sorted) {
|
||||
if (v.exts.length === 0) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
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,
|
||||
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,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab with the same dedupeKey exists, focus it instead.
|
||||
* Mirrors the three branches the old `openTab` hardcoded (single/editor/diff).
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
const key = descriptor.dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
splits: mapLeaf(state.splits, targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ?? ''
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import clsx from 'clsx'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
import type { DropZone } from './state.ts'
|
||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
||||
@@ -108,7 +107,7 @@ function PaneEmptyCards(props: {
|
||||
title={option.label}
|
||||
onClick={() => { onNewTab(option.id) }}
|
||||
>
|
||||
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
|
||||
{option.icon ?? null}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -123,8 +122,9 @@ function LeafView(props: {
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
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]
|
||||
|
||||
@@ -177,6 +177,7 @@ function LeafView(props: {
|
||||
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)
|
||||
@@ -215,8 +216,9 @@ function NodeView(props: {
|
||||
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 } = props
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
@@ -225,6 +227,7 @@ function NodeView(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -250,6 +253,7 @@ function NodeView(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
@@ -265,8 +269,9 @@ export function Workbench(props: {
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
@@ -276,6 +281,7 @@ export function Workbench(props: {
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+26
-34
@@ -11,7 +11,18 @@
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff'
|
||||
/**
|
||||
* Tab type identifier. Builtins use the strings below; external plugins
|
||||
* register their own ids (e.g. `'my-plugin:db'`) through the sidebar
|
||||
* service. Kept as `string` so the registry is open; {@link SIDEBAR_BUILTIN_TAB_TYPES}
|
||||
* lists the builtins for back-compat checks.
|
||||
*/
|
||||
export type TabType = string
|
||||
|
||||
/** The tab type ids the plugin itself registers (see {@link ./builtins.ts}). */
|
||||
export const SIDEBAR_BUILTIN_TAB_TYPES = [
|
||||
'explorer', 'git', 'editor', 'terminal', 'subagent', 'diff',
|
||||
] as readonly string[]
|
||||
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
@@ -298,15 +309,13 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
|
||||
function isSingle(type: TabType): boolean {
|
||||
return type === 'explorer' || type === 'git' || type === 'subagent'
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab (or focus its existing instance). Single-instance types focus
|
||||
* the existing tab wherever it lives; editors dedupe by path. The tab lands
|
||||
* in the active pane (or the first pane when the active one is gone).
|
||||
* Open a tab (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/openDiffTab already
|
||||
* check existence before calling) and lands the tab in the active pane
|
||||
* (or the first pane when the active one is gone).
|
||||
*/
|
||||
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
@@ -315,24 +324,10 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
if (isSingle(tab.type)) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'editor' && tab.path !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'diff') {
|
||||
// One tab per change: the same path+side (or commit) focuses its instance.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.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,
|
||||
@@ -622,13 +617,10 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
droppedDiff = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
candidate.type !== 'explorer' && candidate.type !== 'git'
|
||||
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
|
||||
&& candidate.type !== 'subagent'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
// 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,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
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 {
|
||||
@@ -309,6 +310,12 @@ declare module 'cordis' {
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,19 @@ import { readJsonBody, requireString, SidebarError, writeError, writeJson, write
|
||||
|
||||
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'
|
||||
|
||||
/** Plugin identity for cordis.yml rows. */
|
||||
export const name = 'dsh-better-sidebar'
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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 } 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('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 overrides exts when head bytes are available', () => {
|
||||
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: [],
|
||||
priority: 5,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// Without head bytes, by-ext wins (priority 10 > 5, and .bin matches).
|
||||
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
|
||||
// With magic bytes, by-magic wins (detect overrides).
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
|
||||
})
|
||||
|
||||
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 each time', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
+18
-9
@@ -214,23 +214,26 @@ describe('git parsing', () => {
|
||||
describe('sidebar state', () => {
|
||||
const state = (): SidebarState => makeDefaultState()
|
||||
|
||||
it('opens tabs into the active pane and dedupes single-instance types', () => {
|
||||
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
|
||||
let s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
s = openTab(s, gitTab)
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening git focuses the existing tab instead of duplicating.
|
||||
const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
// Reopening with the SAME id focuses the existing tab instead of duplicating.
|
||||
const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// A different id opens a new tab (type-level dedupe is the service's job).
|
||||
const after2 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('dedupes editors by path', () => {
|
||||
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
|
||||
let s = state()
|
||||
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
|
||||
})
|
||||
|
||||
const diffTab = (id: string): SidebarTab => ({
|
||||
@@ -644,10 +647,16 @@ describe('persisted state sanitization', () => {
|
||||
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
|
||||
expect(sanitizeState(withSplit)).toBeUndefined()
|
||||
// Unknown tab types (older layouts) are rejected.
|
||||
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadTab.splits.tabs[0].type = 'watcher'
|
||||
expect(sanitizeState(withBadTab)).toBeUndefined()
|
||||
// Unknown tab types (external plugins not yet loaded) are accepted —
|
||||
// they render as <OrphanedTab/> at view time and recover if the plugin
|
||||
// loads later. Only diff tabs are dropped (ephemeral).
|
||||
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
|
||||
const externalClean = sanitizeState(withExternalTab)
|
||||
expect(externalClean).toBeDefined()
|
||||
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
|
||||
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
|
||||
}
|
||||
// An active id that no tab carries is rejected.
|
||||
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadActive.splits.active = 'ghost-tab'
|
||||
|
||||
Reference in New Issue
Block a user