Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983d2b2d71 | ||
|
|
6e4474cd2e | ||
|
|
88f116338a | ||
|
|
b90251cce5 | ||
|
|
8c521be26e | ||
|
|
27bc49830e | ||
|
|
96b83ae3b8 | ||
|
|
cb9a7cf751 | ||
|
|
ffc2270d77 | ||
|
|
b391ebffed | ||
|
|
bab659f8bd | ||
|
|
bf54a60e37 | ||
|
|
c7af7dccdb | ||
|
|
7eccc49f6e | ||
|
|
d1c393a048 | ||
|
|
a465bdee38 | ||
|
|
31b78f7941 | ||
|
|
956fcc005b | ||
|
|
302c20109f | ||
|
|
35cde82eb2 | ||
|
|
67ec136e98 | ||
|
|
8f3bb1b264 | ||
|
|
80f4406ff2 | ||
|
|
ff94d0c124 | ||
|
|
0e20413641 | ||
|
|
1b0d2085bb | ||
|
|
89ddf8a9e7 | ||
|
|
89d2c65707 | ||
|
|
7c792c2a96 | ||
|
|
f2d47f1437 | ||
|
|
53a9ced6bf | ||
|
|
1074dc8678 | ||
|
|
8e6d0d799c | ||
|
|
8b53c0a3ab | ||
|
|
2648c0f8e2 | ||
|
|
39455ff18e | ||
|
|
1b24bc8ff2 | ||
|
|
0467101faf | ||
|
|
db0ba36610 | ||
|
|
7687204b43 | ||
|
|
e40039ceec | ||
|
|
291158a3ba | ||
|
|
fbebcf5138 |
@@ -145,3 +145,4 @@ lib/
|
||||
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
# dsh-better-sidebar 插件接入文档
|
||||
|
||||
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
|
||||
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 仓库硬约束(所有本仓库插件必须遵守)
|
||||
|
||||
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
|
||||
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
---
|
||||
|
||||
## 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`(别名 `./client/api`)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 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `TabComponentProps`
|
||||
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? }
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 注册示例
|
||||
|
||||
**最简 tab**(单实例、+ 菜单可见):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**多实例 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 自增) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
|
||||
---
|
||||
|
||||
## 4. FileViewer 注册 API
|
||||
|
||||
### 4.1 `FileViewerDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
priority?: number
|
||||
/** 字节获取策略 */
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `fetchStrategy` 对照
|
||||
|
||||
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
### 4.4 匹配算法
|
||||
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
|
||||
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
|
||||
**CSV 预览器**(自定义加载 + 渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const text = await fetchText(scope, path)
|
||||
return parseCsv(text)
|
||||
},
|
||||
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**覆盖内置 image viewer**(如想用自定义的 SVG 优化渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:svg-pro',
|
||||
exts: ['svg'],
|
||||
priority: 10, // 高于内置 image 的 0
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:magic-parquet',
|
||||
exts: [], // catch-all,但 priority 高 + detect 精确命中
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head.length >= 4
|
||||
&& head[0] === 0x50 && head[1] === 0x41
|
||||
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
|
||||
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
|
||||
component: ({ customData }) => <ParquetTable data={customData} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 服务方法完整清单
|
||||
|
||||
```ts
|
||||
interface BetterSidebarService {
|
||||
/** 注册 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
||||
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
||||
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生命周期与 HMR
|
||||
|
||||
- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
|
||||
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。
|
||||
- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。
|
||||
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。
|
||||
|
||||
---
|
||||
|
||||
## 7. 平台约束与陷阱
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
|
||||
## 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/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
@@ -1,38 +1,45 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
|
||||
> 一个插件,一套完整工作台:
|
||||
>
|
||||
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
>
|
||||
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
|
||||
|
||||
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
|
||||
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
## 🚀 通过 plugin-registry 安装(标准)
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
|
||||
## ✨ 功能一览
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用——信任边界)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar # 启用
|
||||
```
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止)
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
|
||||
> **通道互斥**:registry 安装与下方「不依赖 plugin-registry 的官方 profile 安装」是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
|
||||
## ⌨️ 快捷键
|
||||
|
||||
### 更新(registry 通道)
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
```sh
|
||||
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
## 🚀 安装
|
||||
|
||||
## 🚀 一键安装(把提示词发给 DSH)
|
||||
|
||||
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
|
||||
```text
|
||||
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
@@ -40,271 +47,86 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
|
||||
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
|
||||
2. 注册到 web profile:
|
||||
a. 编辑 ~/.dsh/profiles/web/package.json,在 dependencies 中加入 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. 编辑 ~/.dsh/profiles/web/cordis.patch.yml,追加挂载行:
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
3. 在 ~/.dsh/profiles/web 目录执行 pnpm install
|
||||
4. 全部完成后告诉我,我重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
3. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
```
|
||||
|
||||
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
|
||||
|
||||
### 更新(同样把提示词发给 DSH)
|
||||
|
||||
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
|
||||
|
||||
```text
|
||||
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
|
||||
|
||||
1. 拉取最新代码并重新构建:
|
||||
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
|
||||
2. 核对注册仍然有效(缺失才需要补):
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
|
||||
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
|
||||
3. 完成后告诉我本次改动涉及 client 还是 host:
|
||||
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
|
||||
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
|
||||
```
|
||||
|
||||
## 为什么用它
|
||||
|
||||
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
|
||||
- **🧠 按会话记忆与隔离**:每个 conversation 拥有独立的布局、分栏、Tab 与目录展开状态,切换会话即切换整套工作台
|
||||
- **🎨 与 GUI 浑然一体**:完全复用 DSH 主题变量与官方组件,明暗主题自适应,观感就是原生的
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
### 🗂️ 文件资源管理器
|
||||
|
||||
- 懒加载目录树,根目录 = 当前会话工作目录(cwd);目录优先、隐藏文件变暗、每层 1000 项上限
|
||||
- 点击文件在侧边栏打开编辑器;刷新按钮重载
|
||||
- **悬浮行尾 `@文件` 按钮**:一键把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`),引用文件不再手打路径
|
||||
- **右键行 → 复制相对地址 / 复制绝对地址**,复制成功行内短暂显示"已复制"
|
||||
|
||||
### 📝 文件编辑与预览
|
||||
|
||||
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
|
||||
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
|
||||
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
|
||||
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
|
||||
- **Office 三件套预览**:
|
||||
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
|
||||
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
|
||||
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
|
||||
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
|
||||
|
||||
### 💻 终端
|
||||
|
||||
- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限)
|
||||
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
|
||||
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
|
||||
- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:<uuid>`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用
|
||||
|
||||
### 🌿 Git 面板
|
||||
|
||||
- **真正的 diff 显示**:解析 `git diff` 统一格式——`@@` hunk 头、旧/新双行号对齐、上下文/删除/新增行红绿着色(DSH 语义色 token),长行自动换行、无横向滚动;二进制文件显示提示、超长 diff 折叠展开、untracked 文件渲染为全新增视图
|
||||
- **点击即开 diff tab(VSCode 式)**:点击变更文件行或历史提交行,自动打开独立 diff tab——首次打开时当前栏纵向一分为二、diff 默认落在**下半栏**,之后新 diff 堆叠进同一栏;同一变更(路径+暂存侧 / 提交)只存在一个实例,重复点击聚焦已有 tab;diff tab 为临时页签(刷新后不恢复,同 VSCode diff 编辑器),带手动刷新按钮
|
||||
- **VSCode 式历史**:提交行 = 短哈希 + 主题,第二行 = 分支/标签徽章 + 作者 · 相对时间;**懒加载分页**(每页 20 条,底部「加载更多」,避免长历史挤爆面板)
|
||||
- **右键高级操作**:
|
||||
- 文件行:在编辑器中打开 / 暂存 / 取消暂存 / 放弃更改(确认弹窗)/ 复制相对·绝对路径
|
||||
- 历史行:查看提交差异 / 复制短哈希 / 复制完整哈希 / 复制提交信息 / 还原此提交(revert)/ 捡取此提交(cherry-pick),破坏性操作均有确认弹窗
|
||||
- 暂存/未暂存按 git XY 状态精确分组(`MM` 双区显示)、stage/unstage(单个与全部)、commit(Ctrl+Enter)、分支切换
|
||||
|
||||
### 🧩 Subagent 页面
|
||||
|
||||
- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
|
||||
- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
|
||||
- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
|
||||
- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
|
||||
|
||||
### 🧩 VSCode 式分栏工作台
|
||||
|
||||
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
|
||||
- 分隔线拖拽调整比例;面板宽度从左侧缘拖拽;折叠滑出隐藏 + 全屏展开
|
||||
|
||||
### 📑 Tab 栏
|
||||
|
||||
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
|
||||
- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
|
||||
- 空分栏显示可打开类型卡片,点击即开
|
||||
|
||||
### 🔁 会话隔离与状态自愈
|
||||
|
||||
- 布局/分栏/Tab/目录展开按会话持久化(localStorage),切换会话即切换整套状态
|
||||
- **陈旧状态自动净化**:结构校验、宽度钳制视口、损坏的重复 id 自动重新编号——刷新后分栏与新生成的 id 永不冲突,不会出现"文件开进两个分栏"
|
||||
- 侧边栏**占用布局**(挤窄对话列而非悬浮遮挡,动画过渡)
|
||||
- 拦截对话"产出文件"行:点击改为在侧边栏打开,而不是系统默认程序
|
||||
|
||||
### 🎨 界面风格
|
||||
|
||||
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
|
||||
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
|
||||
|
||||
### ⚙️ Side card 设置(DSH 设置页)
|
||||
|
||||
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分分栏 | 拖 Tab 到分栏边缘 |
|
||||
| 合并分栏 | 拖 Tab 到分栏中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
---
|
||||
|
||||
## 🧰 手动安装与更新
|
||||
|
||||
不想用提示词时,也可以手动完成(等价于上面提示词的步骤):
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
|
||||
- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供
|
||||
|
||||
### 1. 克隆、安装、构建
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install
|
||||
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
|
||||
```
|
||||
|
||||
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
|
||||
|
||||
### 2. 注册到 web profile
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
|
||||
```
|
||||
|
||||
(等价于手动把 `"dsh-better-sidebar": "link:<绝对路径>"` 写入 `~/.dsh/profiles/web/package.json` 的 `dependencies` 并 `pnpm install`。)
|
||||
|
||||
然后编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行):
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
### 3. 重启 DSH 并刷新页面
|
||||
|
||||
重启 DSH(你的 `dsh web` 启动方式),浏览器**硬刷新**(Cmd/Ctrl+Shift+R)后,右侧出现侧边栏即安装成功。
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
|
||||
### 更新
|
||||
|
||||
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
|
||||
|
||||
```sh
|
||||
git pull && pnpm install && pnpm build # 在插件仓库目录执行
|
||||
```text
|
||||
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
|
||||
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
|
||||
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
|
||||
- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ 重启 DSH + 硬刷新
|
||||
### 通过 plugin-registry 安装(可选,与上述二选一)
|
||||
|
||||
`link:` 引用无需重新 install。
|
||||
|
||||
### 通过 registry 安装(手动,与上面的官方方案二选一)
|
||||
|
||||
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
pnpm build # 先构建(产物见上)
|
||||
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
```sh
|
||||
git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
|
||||
- 改了 **host** 代码 → 重启 DSH + 硬刷新
|
||||
- 与官方方案的互斥见上文「通道互斥」说明
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
|
||||
## 🛠️ 开发
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
|
||||
pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
### 规范符合性
|
||||
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
|
||||
|
||||
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
|
||||
## 🔐 安全
|
||||
|
||||
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
|
||||
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
|
||||
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
|
||||
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
|
||||
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
|
||||
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
|
||||
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
|
||||
- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
|
||||
|
||||
## 🏗️ 架构
|
||||
## ⚠️ 已知限制
|
||||
|
||||
一个 npm 包,host/client 双半结构(与 DSH monorepo 内 client 包同构):
|
||||
|
||||
| 半 | 入口 | 职责 |
|
||||
|---|---|---|
|
||||
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
|
||||
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
|
||||
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
|
||||
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
|
||||
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
|
||||
|
||||
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
|
||||
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
|
||||
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
|
||||
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
|
||||
|
||||
## 🔐 安全边界
|
||||
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
|
||||
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件
|
||||
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份
|
||||
|
||||
## ⚠️ 已知限制(v1 非目标)
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
|
||||
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
|
||||
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
|
||||
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
|
||||
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
代码按 **Windows / Linux / macOS** 三平台适配(macOS 为日常验证平台;Windows/Linux 的平台分支经单元测试覆盖,建议真机冒烟):
|
||||
|
||||
- 路径处理全部走 `node:path` 平台 API(列表拼接、`resolve`);媒体路由的前缀检查与相对路径投影容忍混合分隔符,Windows 上大小写不敏感
|
||||
- Windows 上终端 shell 为 `powershell.exe`;node-pty 的 spawn-helper 修复(pnpm 剥执行位)自动跳过 Windows
|
||||
- `node-pty` 安装优先下载预编译二进制(`prebuild.js`,需网络),失败则源码编译——Windows 需 Visual Studio Build Tools,Linux 需 `make`/`g++`/`python3`,macOS 需 Xcode Command Line Tools;`pnpm-workspace.yaml` 已声明 `allowBuilds: node-pty: true`
|
||||
- git 操作仅依赖 `git` 在 PATH(Windows 需 Git for Windows)
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览:你能扩展什么
|
||||
|
||||
better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
|
||||
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
|
||||
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
|
||||
|
||||
内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
|
||||
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
|
||||
|
||||
> ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置:类型合并与依赖声明
|
||||
|
||||
### 2.1 双 cordis 实例问题
|
||||
|
||||
外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供:
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
|
||||
```
|
||||
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §9)。
|
||||
|
||||
### 2.2 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.3 类型导入路径
|
||||
|
||||
```ts
|
||||
// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
TabDescriptor,
|
||||
TabComponentProps,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
FileFetchStrategy,
|
||||
} from 'dsh-better-sidebar'
|
||||
|
||||
// 方式二:子路径(与主入口等价)
|
||||
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 最小骨架(client half)
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ scope }) => <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} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。
|
||||
- `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
|
||||
- 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。
|
||||
|
||||
---
|
||||
|
||||
## 4. 新页面(Tab)注册 API
|
||||
|
||||
### 4.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 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):见 §7。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
|
||||
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
实践建议:
|
||||
|
||||
- **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。
|
||||
- **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。
|
||||
|
||||
### 4.3 注册示例
|
||||
|
||||
**最简单实例 tab**(+ 菜单可见):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**多实例 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' })
|
||||
```
|
||||
|
||||
**条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:commits',
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (ctx, scope, state) => hasGitRepo(state),
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**自定义创建**(mint 自增 id,terminal 内置页同款):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:console',
|
||||
title: 'Console',
|
||||
order: 80,
|
||||
createTab: (state) => ({
|
||||
tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: `Console ${state.nextTerminal}` },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 }, // 借内置计数器;也可自建 state 字段
|
||||
}),
|
||||
component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4 内置 tab 清单(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件预览器(FileViewer)注册 API
|
||||
|
||||
### 5.1 `FileViewerDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
priority?: number
|
||||
/** 字节获取策略 */
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.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` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
### 5.4 匹配算法(`matchFileViewer`)
|
||||
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领);
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。
|
||||
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 5.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} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 页面内如何访问数据(/sidebar API)
|
||||
|
||||
你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):
|
||||
|
||||
```ts
|
||||
// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
|
||||
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() // 错误时 { ok: false, error: { code, message } }
|
||||
```
|
||||
|
||||
常用方法(完整清单见 `src/client/api.ts`):
|
||||
|
||||
| 方法 | 说明 |
|
||||
|---|---|
|
||||
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
|
||||
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
|
||||
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
|
||||
| `fs.write` | 原子写文件 |
|
||||
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
|
||||
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
|
||||
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
|
||||
|
||||
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
|
||||
|
||||
```ts
|
||||
// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
|
||||
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
|
||||
```
|
||||
|
||||
> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务方法完整清单
|
||||
|
||||
```ts
|
||||
interface BetterSidebarService {
|
||||
/** 注册 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 声明式设置(v0.4.1+)
|
||||
|
||||
每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码):
|
||||
|
||||
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
|
||||
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
|
||||
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: 'Database',
|
||||
order: 50,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下
|
||||
title: 'Auto connect',
|
||||
desc: 'Connect on open',
|
||||
}],
|
||||
},
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 生命周期与 HMR
|
||||
|
||||
- **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
|
||||
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。
|
||||
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。
|
||||
- **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。
|
||||
|
||||
---
|
||||
|
||||
## 10. 平台约束与陷阱
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 |
|
||||
| **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`) |
|
||||
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
|
||||
|
||||
---
|
||||
|
||||
## 11. 完整最小示例
|
||||
|
||||
假设插件 `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(custom 策略:自己拉字节 + 解析)
|
||||
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**:
|
||||
|
||||
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
|
||||
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
|
||||
3. 在 profile 目录 `pnpm install`;
|
||||
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 参考实现与调试
|
||||
|
||||
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
|
||||
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
@@ -0,0 +1,137 @@
|
||||
# 侧边栏设置声明式化 + 内置支持"插头化"设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.4.1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
|
||||
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
|
||||
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
|
||||
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
|
||||
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
|
||||
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
|
||||
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
|
||||
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
|
||||
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
|
||||
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
openByDefault: boolean
|
||||
defaultWidthPercent: number
|
||||
autoOpenSubagent: boolean
|
||||
agentTerminalTools: boolean
|
||||
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
|
||||
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
|
||||
}
|
||||
```
|
||||
|
||||
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
|
||||
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
|
||||
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
|
||||
|
||||
### 4.2 声明式描述符(`client/service.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarSettingToggle {
|
||||
key: string // SidebarPrefs 字段名('autoOpenSubagent')
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
interface SidebarSettingsDeclaration {
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
// TabDescriptor 增加:
|
||||
settings?: SidebarSettingsDeclaration
|
||||
|
||||
// FileViewerDescriptor 增加(设置清单展示用):
|
||||
title?: string | (() => string) // 缺省回退 id
|
||||
icon?: ReactNode | ((size) => ReactNode)
|
||||
settings?: SidebarSettingsDeclaration
|
||||
```
|
||||
|
||||
服务新增:
|
||||
```ts
|
||||
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
|
||||
isViewerEnabled(id: string): boolean
|
||||
```
|
||||
|
||||
行为变化:
|
||||
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
|
||||
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
|
||||
|
||||
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
|
||||
|
||||
| tab | 相关设置声明 |
|
||||
|---|---|
|
||||
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
|
||||
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
|
||||
| explorer/git/editor/diff | 无(仅开关) |
|
||||
|
||||
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
|
||||
|
||||
### 4.4 Store 快照携带 prefs(`client/state.ts`)
|
||||
|
||||
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
|
||||
|
||||
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
|
||||
|
||||
### 4.5 消费点 gating
|
||||
|
||||
| 消费点 | 行为 |
|
||||
|---|---|
|
||||
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
|
||||
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
|
||||
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
|
||||
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
|
||||
| `openTab` / `matchFileViewer` | 见 §4.2 |
|
||||
| 新会话默认布局 | explorer 禁用 → 空 pane |
|
||||
|
||||
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
|
||||
|
||||
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
|
||||
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
|
||||
- 行结构:
|
||||
- **常规**:`openByDefault`、宽度(既有逻辑)。
|
||||
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
|
||||
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
|
||||
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
|
||||
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
|
||||
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
|
||||
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
|
||||
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
|
||||
|
||||
## 6. 限制与失败模式
|
||||
|
||||
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
|
||||
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
|
||||
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
|
||||
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
|
||||
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
|
||||
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
|
||||
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
|
||||
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
|
||||
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
|
||||
@@ -0,0 +1,136 @@
|
||||
# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已批准,已实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.4.3(main `35cde82`)
|
||||
**目标版本**:v0.5.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力:
|
||||
|
||||
1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。
|
||||
2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
|
||||
|
||||
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
|
||||
|
||||
## 2. 复用门(dsh-reuse-first)
|
||||
|
||||
`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
|
||||
|
||||
## 3. 威胁模型与安全基线
|
||||
|
||||
| 威胁 | 对策 |
|
||||
|---|---|
|
||||
| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src |
|
||||
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` |
|
||||
| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) |
|
||||
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 |
|
||||
| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 |
|
||||
| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) |
|
||||
| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 |
|
||||
| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 |
|
||||
| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 |
|
||||
|
||||
## 4. 变更清单
|
||||
|
||||
### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts)
|
||||
|
||||
URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域:
|
||||
|
||||
```
|
||||
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
|
||||
例:/sidebar/html/S/Users/me/proj/index.html
|
||||
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
|
||||
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
||||
```
|
||||
|
||||
- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。
|
||||
- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头:
|
||||
`content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。
|
||||
- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。
|
||||
|
||||
### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
|
||||
|
||||
- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。
|
||||
- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染:
|
||||
`<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />`
|
||||
- 导出 `HTML_IFRAME_SANDBOX` 常量(测试钉死);沙箱关闭时 `sandbox` 属性整体移除 + 渲染 `sandboxWarning` 警告条。
|
||||
- 预览显示**已保存文件**(route-src),不反映未保存草稿(有意取舍,文档化)。
|
||||
|
||||
### 4.3 client:`browser` tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
|
||||
|
||||
- state.ts:`SidebarState.nextBrowser`(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducer `patchTab(state, tabId, {title?, path?})`。
|
||||
- tabs.tsx:`{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }`。
|
||||
- browser.ts(无依赖纯模块):`isLoopbackHostname`(localhost/::1/0.0.0.0/127/8);`normalizeBrowserUrl(input, selfOrigin)`:
|
||||
- 无 scheme → 补 `https://`;`://` 完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;`host:port` 形态(如 `example.com:8080`)不被误判为 scheme。
|
||||
- 拒绝顺序:协议 → 自身 origin **放行**(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
|
||||
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经 `patchTab` 持久化到 `tab.path`/`tab.title`(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。
|
||||
- iframe:`sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"`(导出 `BROWSER_IFRAME_SANDBOX`;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。
|
||||
- 常驻底部小字沙箱说明(`browserHint`)。
|
||||
|
||||
### 4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
|
||||
|
||||
- `SidebarPrefs` 新增 `htmlViewerNoSandbox` / `browserNoSandbox`(boolean,默认 false);`SIDEBAR_PREFS_DEFAULTS` 与 `PrefsSchema` 同步;`parsePrefs` 按字段校验回退。
|
||||
- 声明式设置:html viewer 行与 browser tab 行各自 `settings.toggles`,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
|
||||
|
||||
### 4.5 其他
|
||||
|
||||
- `api.ts`:`htmlUrl(scope, path)` = `encodeHtmlUrl(sessionId, path)`。
|
||||
- `icons.tsx`:`IconHtmlOutline16`(文件 + `‹/›`)、`IconGlobeOutline16`(地球)。
|
||||
- `locales.ts`:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。
|
||||
- `sidebar.module.css`:`sandboxWarning`、`editorHtml`、`.browser*` 系列。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
| 文件 | 覆盖 |
|
||||
|---|---|
|
||||
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
|
||||
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
|
||||
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
|
||||
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
|
||||
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
|
||||
| tests/smoke.spec.ts | fenced routes 清单 + `/sidebar/html` |
|
||||
|
||||
## 6. 版本与文档
|
||||
|
||||
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
|
||||
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. `pnpm typecheck && pnpm build && pnpm test` 全绿(245 用例)。
|
||||
2. 手动:打开 `.html` 默认预览渲染(相对资源可加载),可切编辑/保存;`+` 菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。
|
||||
3. 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;`javascript:`/`data:`/`file:`/localhost/127.x 均被阻止并提示(自身 origin 允许);`/sidebar/html` 越界 403、超限 400、跨站 403;预览页 fetch `/sidebar/api` 读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。
|
||||
4. 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
|
||||
|
||||
## 8. 实施偏差记录
|
||||
|
||||
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
|
||||
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
|
||||
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
|
||||
|
||||
## 8.1 追加需求(2026-08-11,v0.5.0 内)
|
||||
|
||||
1. **设置页滚动**:`SideCardSection.module.css` 的 `.section` 填满设置壳 options 区并内部滚动(`height:100% + min-height:0 + overflow-y:auto`),7 tab + 9 viewer 卡片再多也不会被裁切。
|
||||
2. **沙箱状态实时显示 + 临时解锁**:新增 `SandboxStatusBar`(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示**红色警示**(`state-error-primary` 色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。
|
||||
3. **聊天/界面外链在侧边栏打开**:新设置 `browserInterceptLinks`(默认开,受浏览器 tab 启用开关 gate)——`link-intercept.ts` 在 document capture 阶段拦截 http(s) **外部**链接点击(同源/非 http(s)/修饰键点击一律放行),经 `openTab({type:'browser', url, title: hostname})`(openTab seed 新增 `url` 字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(`window.open(url,'_blank','noopener')`,用户手势触发,等价真实浏览器新标签)。
|
||||
|
||||
## 8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
|
||||
|
||||
1. **HTML「默认非安全」二级设置**:`htmlViewerDefaultUnsafe`(默认 false)——打开 HTML 文件时预览**初始**处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与 `htmlViewerNoSandbox`(全局强制关闭,不可界面恢复)语义区分。
|
||||
2. **沙箱状态行单行化并取代底部说明**:浏览器 tab 底部常驻 `browserHint` 条移除,说明并入 `SandboxStatusBar` 开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行 `nowrap + ellipsis + title` 恒为一行;关闭态同样单行红色警示。
|
||||
3. **「在浏览器中打开」图标放大**:`IconRightUpOutline14` → `IconRightUpOutline16`(15px 渲染)。
|
||||
|
||||
## 8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
|
||||
|
||||
1. **图标改 15px**:`<IconRightUpOutline16 size={15} />`。
|
||||
2. **嵌入拒绝面板**:站点通过 `X-Frame-Options` / CSP `frame-ancestors` 禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示 `BrowserEmbedBlocked` 面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(`window.open`)+ 「仍然加载」两个按钮。检测:新增 host 路由 `browser.probe`(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的 `x-frame-options`/`frame-ancestors`,受信任围栏保护)+ 客户端 `embeddabilityOf` 判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含 `*` → blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经 `useEffect(url)` 触发;「仍然加载」以 `forceEmbed` 保留普通 iframe。
|
||||
|
||||
## 9. 已知限制与残余风险
|
||||
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
|
||||
- iframe 内部链接跳转不进后退栈(跨域不可见);被 `X-Frame-Options`/`frame-ancestors` 拒绝的站点显示空白(站点策略,无法绕过)。
|
||||
- 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
|
||||
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。
|
||||
@@ -0,0 +1,416 @@
|
||||
# DSH-better-sidebar 服务化改造设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已批准,待实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.3.0(main `069c754`)
|
||||
**目标版本**:v0.4.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
|
||||
|
||||
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
|
||||
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
|
||||
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
|
||||
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
|
||||
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
### 3.1 Tab 系统硬编码点(7 处)
|
||||
|
||||
| 文件:行 | 用途 |
|
||||
|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
|
||||
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
|
||||
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
|
||||
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
|
||||
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
|
||||
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
|
||||
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
|
||||
|
||||
### 3.2 文件预览硬编码点
|
||||
|
||||
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
|
||||
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
|
||||
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
|
||||
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
|
||||
|
||||
### 3.3 服务暴露现状
|
||||
|
||||
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
|
||||
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
|
||||
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
|
||||
|
||||
### 3.4 平台约束
|
||||
|
||||
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
|
||||
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
|
||||
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
|
||||
|
||||
## 4. 方案选型
|
||||
|
||||
| 方案 | 机制 | 评估 |
|
||||
|---|---|---|
|
||||
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
|
||||
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
|
||||
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
|
||||
|
||||
**采纳方案 A**。
|
||||
|
||||
## 5. 服务接口设计
|
||||
|
||||
### 5.1 主服务接口
|
||||
|
||||
```ts
|
||||
// 新文件 src/client/api.ts
|
||||
export interface BetterSidebarService {
|
||||
/** 注册一种 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册一种文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 工具方法:让外部 tab 也能开/关 tab */
|
||||
openTab(tab: { type: string; title: string; path?: string }): void
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅 registry 变化(注册/卸载时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 TabDescriptor
|
||||
|
||||
```ts
|
||||
export interface TabDescriptor {
|
||||
id: string // 唯一,如 'explorer' / 'my-plugin:db'
|
||||
title: string | (() => string) // i18n 友好
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
order?: number // + 菜单排序,默认 100
|
||||
single?: boolean // 单实例(explorer/git/subagent)
|
||||
hidden?: boolean // 不出现在 + 菜单(editor 用)
|
||||
available?: (ctx: Context, scope: SessionScope) => boolean
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 FileViewerDescriptor
|
||||
|
||||
```ts
|
||||
export interface FileViewerDescriptor {
|
||||
id: string // 'image' / 'pdf' / 'my-plugin:csv'
|
||||
exts: readonly string[] // 小写无点,如 ['png','jpg']
|
||||
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Cordis 类型合并 outlet
|
||||
|
||||
```ts
|
||||
// 新文件 src/types.ts (纯类型)
|
||||
import type { BetterSidebarService } from './client/api'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
betterSidebar: BetterSidebarService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
|
||||
|
||||
## 7. package.json exports 新增
|
||||
|
||||
```jsonc
|
||||
"exports": {
|
||||
".": { ... },
|
||||
"./invariant": { ... },
|
||||
"./client": { ... },
|
||||
"./client/api": { // 新增,纯类型导出
|
||||
"types": "./lib/types/client/api.d.ts",
|
||||
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
```
|
||||
|
||||
`files` 字段相应加 `lib/types/client/api.d.ts`。
|
||||
|
||||
## 8. 内置 tab 自我注册(吃狗粮)
|
||||
|
||||
`src/client/index.tsx` 的 `apply()` 内:
|
||||
|
||||
```ts
|
||||
const service = createBetterSidebarService(store)
|
||||
ctx.provide('betterSidebar', service)
|
||||
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
|
||||
```
|
||||
|
||||
### 8.1 5 个内置 tab
|
||||
|
||||
| id | order | single | hidden | available | 组件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
|
||||
| `explorer` | 10 | true | false | always | `ExplorerView` |
|
||||
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
|
||||
| `subagent` | 30 | true | false | always | `SubagentView` |
|
||||
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
|
||||
|
||||
### 8.2 内置 file viewer
|
||||
|
||||
| id | exts | priority | fetchStrategy | 组件 |
|
||||
|---|---|---|---|---|
|
||||
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
|
||||
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
|
||||
| `docx` | docx | 0 | mediaUrl | `DocxView` |
|
||||
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
|
||||
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
|
||||
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
|
||||
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
|
||||
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
|
||||
|
||||
`matchFileViewer(path, head?)` 算法:
|
||||
1. 按 `priority` 降序遍历
|
||||
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
|
||||
3. 否则匹配 `exts`(小写无点)
|
||||
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
|
||||
|
||||
## 9. 7 处硬编码 switch 改造点
|
||||
|
||||
| 文件:行 | 原状 | 改造后 |
|
||||
|---|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
|
||||
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
|
||||
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
|
||||
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
|
||||
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
|
||||
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
|
||||
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
|
||||
|
||||
## 10. 文件预览路由改造
|
||||
|
||||
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path, head?)
|
||||
if (!viewer || viewer.fetchStrategy === 'binary-download') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
|
||||
case 'fsRead':
|
||||
api.fsRead(scope, path).then(({ content, truncated }) =>
|
||||
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
|
||||
)
|
||||
break
|
||||
case 'custom':
|
||||
viewer.load(path, scope).then(data =>
|
||||
setLoad({ status: 'ready', kind: 'custom', customData: data })
|
||||
)
|
||||
break
|
||||
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path)
|
||||
if (!viewer) return <BinaryDownload ... />
|
||||
return <viewer.component {...props} />
|
||||
```
|
||||
|
||||
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
|
||||
|
||||
## 11. 持久化与降级
|
||||
|
||||
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
|
||||
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
|
||||
|
||||
## 12. Consumer 插件使用范例
|
||||
|
||||
### 12.1 Consumer `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 Consumer client half
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.tsx
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
|
||||
export const inject = ['betterSidebar', 'slots']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ scope, store, tab }) => <DbView scope={scope} />,
|
||||
})
|
||||
)
|
||||
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
### 13.1 单元测试
|
||||
|
||||
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
|
||||
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
|
||||
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
|
||||
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
|
||||
|
||||
### 13.2 集成测试
|
||||
|
||||
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
|
||||
|
||||
### 13.3 现有 manifest 守卫
|
||||
|
||||
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
|
||||
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
|
||||
|
||||
## 14. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
|
||||
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
|
||||
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
|
||||
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
|
||||
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
|
||||
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
|
||||
|
||||
## 15. 版本与发布
|
||||
|
||||
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
|
||||
- peerDependencies 不变(不加新依赖)
|
||||
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
|
||||
- 重建 `lib/` + 推送 main + profile 更新
|
||||
|
||||
## 16. 实施顺序(高层)
|
||||
|
||||
1. 拉取 main v0.3.0 到工作分支
|
||||
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
|
||||
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
|
||||
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
|
||||
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
|
||||
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
|
||||
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
|
||||
8. `package.json` 加 `./client/api` exports + `files` 字段
|
||||
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
|
||||
10. 写测试(service / matchFileViewer / builtins / orphaned)
|
||||
11. typecheck + test + build 三件套
|
||||
12. 重建 lib/,提交,推送,更新 profile web
|
||||
|
||||
详细实施计划由 writing-plans skill 生成。
|
||||
|
||||
---
|
||||
|
||||
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
|
||||
|
||||
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
|
||||
|
||||
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
|
||||
|
||||
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
|
||||
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
|
||||
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
|
||||
|
||||
### 17.2 `available` 三参签名
|
||||
|
||||
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
|
||||
|
||||
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
|
||||
|
||||
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
|
||||
|
||||
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
|
||||
|
||||
§10 的 EditorView 职责一分为二:
|
||||
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
|
||||
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
|
||||
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
|
||||
|
||||
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
|
||||
|
||||
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
|
||||
|
||||
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
|
||||
|
||||
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
|
||||
|
||||
### 17.7 + 菜单:disabled 行而非 filter
|
||||
|
||||
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
|
||||
|
||||
### 17.8 保留的 store 级操作(非注册表职责)
|
||||
|
||||
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
|
||||
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
|
||||
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
|
||||
|
||||
### 17.9 内置清单
|
||||
|
||||
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
|
||||
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
|
||||
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
|
||||
|
||||
### 17.10 测试
|
||||
|
||||
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 客户端懒加载分块(lazy chunks)设计
|
||||
|
||||
> 日期:2026-08-12
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
|
||||
|
||||
| 依赖 | 体积 | 用途 |
|
||||
|---|---|---|
|
||||
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
|
||||
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
|
||||
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
|
||||
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
|
||||
| `xterm` | ~0.3MB | 终端 |
|
||||
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
|
||||
| 插件自身代码 | ~0.35MB | — |
|
||||
|
||||
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
|
||||
|
||||
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
|
||||
|
||||
## 2. 机制
|
||||
|
||||
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
|
||||
|
||||
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
|
||||
```js
|
||||
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
|
||||
```
|
||||
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
|
||||
2. **运行期** `loadChunk(name)`:
|
||||
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
|
||||
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
|
||||
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
|
||||
|
||||
## 3. 分块清单
|
||||
|
||||
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|
||||
|---|---|---|---|---|
|
||||
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
|
||||
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
|
||||
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
|
||||
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
|
||||
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
|
||||
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
|
||||
|
||||
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
|
||||
|
||||
## 4. 缓存契约(三层,各带失败路径)
|
||||
|
||||
| 层 | 机制 | 失败路径 |
|
||||
|---|---|---|
|
||||
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
|
||||
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
|
||||
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
|
||||
|
||||
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
|
||||
|
||||
## 5. 对外 API 兼容性(硬约束)
|
||||
|
||||
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
|
||||
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
|
||||
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
|
||||
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
|
||||
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
|
||||
|
||||
## 6. 边界与失败模式
|
||||
|
||||
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
|
||||
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
|
||||
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
|
||||
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
|
||||
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
|
||||
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
|
||||
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
|
||||
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
|
||||
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
|
||||
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
|
||||
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
|
||||
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 移动端(窄视口 <768px)侧边栏布局设计
|
||||
|
||||
**日期**:2026-08-12
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.7.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
|
||||
|
||||
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
|
||||
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
|
||||
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
|
||||
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
|
||||
- 不改公开服务 API、PrefsSchema、host 半。
|
||||
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
|
||||
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
|
||||
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
|
||||
|
||||
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
|
||||
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
|
||||
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
|
||||
|
||||
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
|
||||
|
||||
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
|
||||
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
|
||||
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
|
||||
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
|
||||
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
|
||||
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
|
||||
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
|
||||
|
||||
### 4.3 窄屏渲染(`Sidebar.tsx`)
|
||||
|
||||
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
|
||||
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
|
||||
- 底部首展自动终端 effect 加 `narrow` 守卫。
|
||||
|
||||
### 4.4 行为修正
|
||||
|
||||
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
|
||||
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
|
||||
|
||||
### 4.5 样式(`sidebar.module.css`)
|
||||
|
||||
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
|
||||
|
||||
### 4.6 测试
|
||||
|
||||
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
|
||||
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
|
||||
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
|
||||
|
||||
## 5. 边界情况
|
||||
|
||||
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
|
||||
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
|
||||
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
|
||||
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
|
||||
|
||||
## 6. 实施偏差记录
|
||||
|
||||
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
|
||||
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
|
||||
|
||||
> 2026-08-12 · v0.8.0
|
||||
|
||||
## 目标
|
||||
|
||||
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
|
||||
|
||||
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
|
||||
- 运行中的任务行带两击确认的终止按钮;
|
||||
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
|
||||
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
|
||||
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
|
||||
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
|
||||
task_output 的 tool/call + tool/result 对 → 模型已读文本
|
||||
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
|
||||
```
|
||||
|
||||
## 关键决策
|
||||
|
||||
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
|
||||
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
|
||||
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
|
||||
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
|
||||
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
|
||||
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
|
||||
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
|
||||
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
|
||||
|
||||
## 实施偏差
|
||||
|
||||
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
|
||||
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
|
||||
|
||||
## 验证
|
||||
|
||||
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
|
||||
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.3.0",
|
||||
"version": "0.8.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
|
||||
+54
-37
@@ -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.8.1",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), 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,24 +19,40 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-docx.js",
|
||||
"lib/client-xlsx.js",
|
||||
"lib/client-pptx.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src"
|
||||
],
|
||||
@@ -50,20 +66,20 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
@@ -104,26 +120,27 @@
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "link:/Users/menghuan/.dsh/source/current/packages/core/agent",
|
||||
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
|
||||
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
|
||||
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
|
||||
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
|
||||
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
|
||||
"@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm",
|
||||
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
|
||||
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
|
||||
"@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
|
||||
Generated
+2325
-30
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,61 @@ packages:
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
|
||||
- '@deepseek-ai/cordis@4.0.1-rc.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-runtime@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-code-runtime@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1'
|
||||
|
||||
@@ -29,6 +29,18 @@ const files = [
|
||||
'lib/invariant.js',
|
||||
'lib/client-registry.js',
|
||||
'lib/client-registry.js.map',
|
||||
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
|
||||
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
|
||||
'lib/client-docx.js',
|
||||
'lib/client-docx.js.map',
|
||||
'lib/client-xlsx.js',
|
||||
'lib/client-xlsx.js.map',
|
||||
'lib/client-pptx.js',
|
||||
'lib/client-pptx.js.map',
|
||||
'lib/client-terminal.js',
|
||||
'lib/client-terminal.js.map',
|
||||
'lib/client-editor.js',
|
||||
'lib/client-editor.js.map',
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Pure helpers for the `browser.probe` route (sidebar browser): the host
|
||||
* fetches the response HEADERS of a URL the user is browsing and the client
|
||||
* decides whether the target site forbids being embedded (X-Frame-Options /
|
||||
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
||||
* refuses an iframe load). Kept dependency-free so the parser is
|
||||
* unit-testable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
||||
* header, or undefined when the directive is absent (or empty). The
|
||||
* directive is the only one with a source list; sources are space-separated
|
||||
* tokens (`'none'`, `'self'`, `*`, or origins).
|
||||
*/
|
||||
export function extractFrameAncestors(csp: string | null): string[] | undefined {
|
||||
if (csp === null) return undefined
|
||||
for (const directive of csp.split(';')) {
|
||||
const parts = directive.trim().split(/\s+/)
|
||||
if (parts[0] === 'frame-ancestors') {
|
||||
const sources = parts.slice(1).filter(source => source !== '')
|
||||
return sources.length === 0 ? undefined : sources
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Lazy chunk route: serves the client bundle's chunk scripts
|
||||
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
||||
* cannot serve arbitrary file names, so the plugin serves its own split
|
||||
* bundles (lib/client-<name>.js) here; the client injects the script on
|
||||
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
||||
*
|
||||
* Caching contract: every response carries `cache-control: no-cache` plus an
|
||||
* ETag (content hash, memoized per file by mtime/size) and honors
|
||||
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
||||
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
||||
* re-activation). Same browser-trust fence as every other /sidebar route;
|
||||
* only allowlisted chunk names are servable (no path traversal).
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { stat, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Context } from './context-types.ts'
|
||||
|
||||
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
||||
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
interface ChunkEtag {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
etag: string
|
||||
}
|
||||
|
||||
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
||||
const etags = new Map<string, ChunkEtag>()
|
||||
|
||||
/**
|
||||
* The chunk file's ETag (quoted hash), or undefined when the file is
|
||||
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
||||
* multi-MB chunk per request is wasteful).
|
||||
*/
|
||||
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
|
||||
const path = join(chunkDir, `client-${name}.js`)
|
||||
const key = `${chunkDir}:${name}`
|
||||
try {
|
||||
const info = await stat(path)
|
||||
const memo = etags.get(key)
|
||||
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
|
||||
return memo.etag
|
||||
}
|
||||
const etag = `"${shortHash(await readFile(path))}"`
|
||||
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
|
||||
return etag
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
||||
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
||||
* chunk scripts live in (overridable for tests).
|
||||
*/
|
||||
export function createBundleRouteHandler(
|
||||
fence: (req: IncomingMessage) => boolean,
|
||||
chunkDir: string = LIB_DIR,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
return async (req, res): Promise<void> => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
|
||||
const name = match?.[1] as ChunkName | undefined
|
||||
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
const etag = await etagOf(name, chunkDir)
|
||||
if (etag === undefined) {
|
||||
// Registered name but unreadable (bundle not built yet): loud 404.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
|
||||
// multi-MB scripts on page refresh / HMR re-activation.
|
||||
res.writeHead(304, { 'cache-control': 'no-cache', etag })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(join(chunkDir, `client-${name}.js`))
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
etag,
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Read raced a delete/rebuild between the stat and the read.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
|
||||
return ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* The built-in browser tab: an address bar plus a sandboxed iframe.
|
||||
*
|
||||
* Security model (see browser.ts + the sandbox tokens below): the iframe is
|
||||
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
|
||||
* page can never sit on the GUI's origin, read its storage, or reach
|
||||
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
|
||||
* the GUI). The address bar only accepts http(s) and refuses loopback /
|
||||
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
|
||||
* sandbox attribute entirely for fully trusted sites — the visited page then
|
||||
* runs with the GUI's own origin and full session access, so a persistent
|
||||
* warning bar renders while it is off.
|
||||
*
|
||||
* The URL is persisted onto the tab (path/title via the patchTab reducer)
|
||||
* so a reload restores the visited page; the back/forward stack only tracks
|
||||
* address-bar navigations (in-frame link clicks are cross-origin and
|
||||
* invisible — a documented limitation).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
IconChevronLeftOutline14,
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
|
||||
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
|
||||
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
|
||||
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
|
||||
* popups open as normal tabs (they are cross-origin to the GUI either way).
|
||||
*/
|
||||
export const BROWSER_IFRAME_SANDBOX =
|
||||
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
|
||||
|
||||
export function BrowserView(props: TabComponentProps) {
|
||||
const { store, tab } = props
|
||||
// The current address (initialized from the persisted tab.path so a
|
||||
// reload restores the visited page).
|
||||
const [url, setUrl] = useState<string | undefined>(tab.path)
|
||||
const [input, setInput] = useState<string>(tab.path ?? '')
|
||||
/** Blocked/invalid hint shown under the address bar (null = none). */
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
/** Address-bar navigation history (in-frame clicks are not tracked). */
|
||||
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
|
||||
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
|
||||
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
|
||||
* side card setting; lasts until the tab unmounts or the user restores). */
|
||||
const [localUnlock, setLocalUnlock] = useState(false)
|
||||
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
|
||||
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
|
||||
* the probe verdict shown instead of the blank iframe. */
|
||||
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
|
||||
/** The user asked to load the refused site anyway (keeps the plain iframe). */
|
||||
const [forceEmbed, setForceEmbed] = useState(false)
|
||||
|
||||
// Probe every navigation (address bar, history, restored path): when the
|
||||
// target forbids embedding, show the reason + open-in-browser instead of
|
||||
// the browser's cryptic "refused to connect" blank frame. A failed probe
|
||||
// (unreachable) keeps the plain iframe.
|
||||
useEffect(() => {
|
||||
if (url === undefined) return
|
||||
let cancelled = false
|
||||
setEmbedBlocked(null)
|
||||
setForceEmbed(false)
|
||||
void api.browserProbe(url).then((probe) => {
|
||||
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
|
||||
}).catch(() => { /* unreachable: keep the plain iframe */ })
|
||||
return () => { cancelled = true }
|
||||
}, [url])
|
||||
|
||||
const persist = (nextUrl: string): void => {
|
||||
let host = nextUrl
|
||||
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
|
||||
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
|
||||
}
|
||||
|
||||
const navigateTo = (raw: string): void => {
|
||||
const result = normalizeBrowserUrl(raw, window.location.origin)
|
||||
if (result.kind === 'ok') {
|
||||
const next = result.url
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setMessage(null)
|
||||
// Push onto the stack, dropping any stale forward entries.
|
||||
setHistory(previous => [...previous.slice(0, cursor + 1), next])
|
||||
setCursor(previous => previous + 1)
|
||||
setReloadKey(key => key + 1)
|
||||
persist(next)
|
||||
return
|
||||
}
|
||||
setMessage(result.kind === 'invalid'
|
||||
? t('browserInvalid')
|
||||
: result.reason === 'scheme' ? t('browserBlockedScheme')
|
||||
: t('browserBlockedLoopback'))
|
||||
}
|
||||
|
||||
const goBack = (): void => {
|
||||
if (cursor <= 0) return
|
||||
const next = history[cursor - 1]!
|
||||
setCursor(cursor - 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
const goForward = (): void => {
|
||||
if (cursor >= history.length - 1) return
|
||||
const next = history[cursor + 1]!
|
||||
setCursor(cursor + 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.browser}>
|
||||
<div className={css.browserBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserBack')}
|
||||
title={t('browserBack')}
|
||||
disabled={cursor <= 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserForward')}
|
||||
title={t('browserForward')}
|
||||
disabled={cursor >= history.length - 1}
|
||||
onClick={goForward}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { setReloadKey(key => key + 1) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
<input
|
||||
className={css.browserInput}
|
||||
value={input}
|
||||
placeholder={t('browserPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={event => { setInput(event.target.value) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') navigateTo(input)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserGo')}
|
||||
title={t('browserGo')}
|
||||
onClick={() => { navigateTo(input) }}
|
||||
>
|
||||
<IconLinkOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserOpenExternal')}
|
||||
title={t('browserOpenExternal')}
|
||||
disabled={url === undefined}
|
||||
onClick={() => {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
<SandboxStatusBar
|
||||
sandboxed={!noSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('browserNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{url === undefined ? (
|
||||
<div className={css.browserStart}>{t('browserStart')}</div>
|
||||
) : embedBlocked !== null && !forceEmbed ? (
|
||||
<BrowserEmbedBlocked
|
||||
url={embedBlocked}
|
||||
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
|
||||
onLoadAnyway={() => { setForceEmbed(true) }}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
||||
className={css.browserFrame}
|
||||
src={url}
|
||||
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed-refusal panel: shown when the probed site forbids being
|
||||
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
|
||||
* iframe would only show the browser's "refused to connect" blank. Explains
|
||||
* the reason and offers the real-browser open plus a load-anyway escape.
|
||||
* Exported so the copy and the actions are testable without a DOM.
|
||||
*/
|
||||
export function BrowserEmbedBlocked(props: {
|
||||
url: string
|
||||
onOpenInBrowser: () => void
|
||||
onLoadAnyway: () => void
|
||||
}) {
|
||||
const { url, onOpenInBrowser, onLoadAnyway } = props
|
||||
let host = url
|
||||
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
||||
return (
|
||||
<div className={css.browserBlocked}>
|
||||
<IconWarningOutline16 size={16} />
|
||||
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
||||
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
||||
<div className={css.browserBlockedActions}>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
|
||||
{t('browserOpenExternal')}
|
||||
</button>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
|
||||
{t('browserEmbedAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
*
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
||||
const apply = (action: EditorLoadAction): void => {
|
||||
if (cancelled) return
|
||||
switch (action.kind) {
|
||||
case 'binary':
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
case 'render':
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
viewer: action.viewer,
|
||||
content: action.content,
|
||||
truncated: action.truncated,
|
||||
mediaUrl: action.mediaUrl,
|
||||
customData: action.customData,
|
||||
})
|
||||
return
|
||||
case 'customLoad':
|
||||
void action.viewer.load?.(path, scope).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
case 'fetchFsRead':
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
// Binary reads carry the head bytes for the detect re-match.
|
||||
const outcome = planFsReadOutcome(action.viewer, {
|
||||
binary: result.kind === 'binary',
|
||||
content: result.kind === 'text' ? result.content : '',
|
||||
truncated: result.truncated,
|
||||
head: result.kind === 'binary' ? result.head : undefined,
|
||||
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
||||
apply(outcome)
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Placeholder shown when a persisted tab's type is not registered (the
|
||||
* contributing plugin is not installed / not yet loaded). The tab is kept
|
||||
* in state so it can recover if the plugin loads later; the user can close
|
||||
* it through the tab bar's X button as usual. This is the graceful-
|
||||
* degradation path for the open tab-type set: {@link Sidebar} renders this
|
||||
* instead of crashing on an unknown type.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
|
||||
export function OrphanedTab(props: TabComponentProps): ReactNode {
|
||||
const { tab } = props
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
|
||||
</div>
|
||||
<div className={css.editorPlaceholder}>
|
||||
<span>{t('pluginNotLoaded')}</span>
|
||||
<code className={css.orphanedType}>{tab.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The live sandbox status row of the two built-in web surfaces (HTML
|
||||
* preview and the browser tab): a green "sandbox on" state with a one-tap
|
||||
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
|
||||
* temporary unlock) with a restore action.
|
||||
*
|
||||
* The temporary unlock is component state only — it never writes the
|
||||
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
|
||||
* it lasts until the surface unmounts (tab switch / file switch) or the
|
||||
* user restores the sandbox from the row. When the global setting already
|
||||
* drops the sandbox, no unlock/restore action is offered (changing the
|
||||
* global setting is the settings page's job) — the red warning stands.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function SandboxStatusBar(props: {
|
||||
/** The effective sandbox state (global pref OR the local temporary unlock). */
|
||||
sandboxed: boolean
|
||||
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
|
||||
local: boolean
|
||||
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
|
||||
dangerCopy: string
|
||||
onUnlock: () => void
|
||||
onRestore: () => void
|
||||
}) {
|
||||
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
|
||||
if (sandboxed) {
|
||||
const copy = t('sandboxStatusOn')
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onUnlock}
|
||||
>
|
||||
{t('sandboxUnlock')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onRestore}
|
||||
>
|
||||
{t('sandboxRestore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,187 @@
|
||||
/**
|
||||
* Side card settings section chrome: the native settings-row recipe (figma
|
||||
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
|
||||
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
|
||||
* The shell strips the last row's separator via the trailing-child rule.
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping);
|
||||
* - a card's on/off state IS its visual state: enabled = brand border +
|
||||
* tinted fill + a check badge pinned to the far right of the top row,
|
||||
* disabled = neutral hairline + dimmed text;
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* opening a native Modal whose rows are the DSH settings-row recipe
|
||||
* (title/desc left + native accent checkbox, hairline separators);
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
and scroll inside the section when the content outgrows the panel. */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
.cardOn {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature. */
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardOn .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
.cardCheck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardOn .cardDesc {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The gear corner button: bottom-right of cards that declare related
|
||||
settings; the desc gets right padding so the ellipsis never runs under it. */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native settings row recipe (the width input row + popup rows). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
@@ -44,7 +203,7 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
@@ -59,6 +218,28 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup body: stacked native rows, trailing separator
|
||||
stripped by the trailing-child rule. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -84,7 +265,7 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+269
-66
@@ -1,21 +1,44 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Three rows:
|
||||
* - new conversations open the panel by default (native checkbox),
|
||||
* - the default panel width as a percent of the window (number input + %),
|
||||
* - auto-open the Subagent page when a new subagent appears (native checkbox,
|
||||
* on by default).
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (a toggle card), and
|
||||
* the default panel width as a percent of the window (number input row).
|
||||
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
|
||||
* external plugins alike), laid out in a responsive grid that wraps
|
||||
* several cards per row — icon + title + type id, clicked to toggle the
|
||||
* switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
|
||||
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
||||
* border + tinted fill + a check badge pinned to the card's far right),
|
||||
* disabled = neutral and dimmed. Features that declare `settings.toggles`
|
||||
* carry a gear corner button that opens a native Modal with the related
|
||||
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
|
||||
* when a subagent appears", the Terminal page's model terminal tools).
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
* new values and the sidebar's consumption points (the + menu, derived
|
||||
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconCodeOutline16,
|
||||
IconPanelLeftOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -29,11 +52,18 @@ import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
@@ -47,15 +77,90 @@ function messageOf(error: unknown): string {
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value. */
|
||||
function textOf(value: string | (() => string) | undefined): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
/** Resolve a descriptor icon (ReactNode or size function). */
|
||||
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
||||
if (icon === undefined) return null
|
||||
return typeof icon === 'function' ? icon(size) : icon
|
||||
}
|
||||
|
||||
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
||||
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
}
|
||||
|
||||
/** Read one boolean pref by declarative key (missing = false). */
|
||||
function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
return (prefs as unknown as Record<string, boolean>)[key] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one native checkbox row
|
||||
* per declared toggle. Extracted so the rows are testable without opening
|
||||
* the Modal (the Modal portal renders only while open).
|
||||
*/
|
||||
export function FeatureSettingsRows(props: {
|
||||
toggles: readonly SidebarSettingToggle[]
|
||||
prefs: SidebarPrefs
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
}) {
|
||||
const { toggles, prefs, onToggle } = props
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
return (
|
||||
<label key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
aria-label={title}
|
||||
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
// load/unload — so a plain effect is enough; no external-store ceremony).
|
||||
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
||||
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
||||
useEffect(() => service.subscribe(() => {
|
||||
setTabs([...service.getTabs()].sort(tabOrder))
|
||||
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
||||
}), [service])
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
@@ -84,7 +189,7 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
@@ -114,25 +219,31 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
togglePref({ openByDefault: next })
|
||||
}
|
||||
|
||||
const onToggleSubagent = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, autoOpenSubagent: next })
|
||||
setError(null)
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
const onToggleTools = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, agentTerminalTools: next })
|
||||
setError(null)
|
||||
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
@@ -149,21 +260,71 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the
|
||||
* check badge sits at the far right, and a feature that declares related
|
||||
* settings carries a gear corner button opening its settings popup.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
desc: string
|
||||
icon?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: (next: boolean) => void
|
||||
/** A feature with declared related settings shows the gear corner button. */
|
||||
onOpenSettings?: () => void
|
||||
}) => {
|
||||
const hasSettings = props.onOpenSettings !== undefined
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={props.enabled}
|
||||
title={props.desc}
|
||||
onClick={() => { props.onToggle(!props.enabled) }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{props.desc}</span>
|
||||
</button>
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardGear}
|
||||
aria-label={`${props.title} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.openByDefault}
|
||||
aria-label={t('settingsOpenTitle')}
|
||||
onChange={event => { onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenTitle'),
|
||||
desc: t('settingsOpenDesc'),
|
||||
icon: <IconPanelLeftOutline16 size={16} />,
|
||||
enabled: prefs.openByDefault,
|
||||
onToggle,
|
||||
})}
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
@@ -187,32 +348,74 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsSubagentTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.autoOpenSubagent}
|
||||
aria-label={t('settingsSubagentTitle')}
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsToolsTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsToolsDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.agentTerminalTools}
|
||||
aria-label={t('settingsToolsTitle')}
|
||||
onChange={event => { onToggleTools(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
{renderCard({
|
||||
title: t('settingsOpenPathTitle'),
|
||||
desc: t('settingsOpenPathDesc'),
|
||||
icon: <IconCodeOutline16 size={16} />,
|
||||
enabled: prefs.interceptOpenPath,
|
||||
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
|
||||
})}
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
grid; features declaring `settings.toggles` open their settings in
|
||||
the popup (gear corner button) instead of nested inline rows. */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 文件预览: one small card per registered file viewer. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The secondary settings popup: a feature's declared related settings
|
||||
as native checkbox rows (Modal chrome is the app's own). Mounted
|
||||
only while a feature is open — the Modal primitive runs hooks
|
||||
unconditionally, so a closed-but-mounted Modal would break SSR
|
||||
(and the renderToString spec) under the test dual-react split. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
closeLabel={t('close')}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
+474
-197
@@ -1,40 +1,52 @@
|
||||
/**
|
||||
* The sidebar shell: a fixed-position right panel portalled onto
|
||||
* document.body (the core AppFrame owns the left sidebar / center / details
|
||||
* columns and has no right-side hole for plugins), plus the edge toggle
|
||||
* button. The panel width drags from its left edge; the whole layout lives
|
||||
* in the per-session store, so switching conversations swaps the sidebar.
|
||||
* The sidebar shell: fixed-position panels portalled onto document.body
|
||||
* (the core AppFrame owns the left sidebar / center / details columns and
|
||||
* has no right-side hole for plugins). The right panel hosts the original
|
||||
* workbench; the bottom panel hosts a second, independent workbench. The
|
||||
* bottom panel squeezes ONLY the center column (the agent output area): it
|
||||
* spans from the app shell's own left sidebar to the right panel's left
|
||||
* edge, so neither sidebar gives up any position (the right panel keeps its
|
||||
* full height). A persistent two-button cluster at the top-right corner
|
||||
* toggles each panel; the right panel's width drags from its left edge, the
|
||||
* bottom panel's height from its top edge, and the shared corner drags both
|
||||
* at once. The whole layout lives in the per-session store, so switching
|
||||
* conversations swaps the sidebar.
|
||||
*
|
||||
* The shell binds the workbench actions to the store and dispatches tab
|
||||
* content to the four views. New tabs come from the + menu (explorer / git /
|
||||
* terminal; editors open from the explorer).
|
||||
* content to the views. New tabs come from the + menu (explorer / git /
|
||||
* terminal; editors open from the explorer). Tabs live in one tree only —
|
||||
* they never cross panels; only the panel sizes drag against each other.
|
||||
*
|
||||
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
|
||||
* narrow migrates the bottom panel's tabs INTO the right tree
|
||||
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
|
||||
* strips. The right panel becomes a full-width drawer, the bottom panel
|
||||
* and its toggle button disappear, and the layout push is disabled (the
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseFill14, 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,
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
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 { detectNewTask } from './subagent-tasks.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
@@ -60,69 +72,48 @@ 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.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { ctx, store } = props
|
||||
|
||||
// Narrow (mobile) viewports collapse the two panels into one: the right
|
||||
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
||||
// panel (and its toggle button) disappears, and the layout push is
|
||||
// disabled (the drawer floats over the app shell). Entering narrow
|
||||
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
|
||||
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
||||
// into its strips. Widening never rewrites the migrated state: the tabs
|
||||
// keep living in the right tree.
|
||||
const narrow = useNarrowViewport()
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
@@ -141,6 +132,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const sessionId = snapshot.sessionId
|
||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
* throw the bottom tree's tabs into the right tree. Idempotent — after
|
||||
* the first migration the bottom tree is empty and the reducer returns
|
||||
* the same reference, so this effect settles immediately.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!narrow || sessionId === undefined) return
|
||||
store.reduce(migrateBottomTabs)
|
||||
}, [narrow, sessionId, store])
|
||||
|
||||
// While the session's header is still hydrating (or the session is blank),
|
||||
// the list summary may carry no cwd; ask the host once (it falls back to
|
||||
// the process cwd) so the explorer root and terminal cwd are real from
|
||||
@@ -167,6 +170,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
@@ -185,7 +190,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => reconcileAgentTerminals(s, list))
|
||||
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
@@ -211,12 +218,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed) and the
|
||||
* "auto open" pref is on, open the panel (if collapsed) and focus the
|
||||
* Subagent page (single-instance: an existing tab is focused, never
|
||||
* duplicated). Switching to a session that already has subagents never
|
||||
* triggers — its baseline starts at the current count — so a deliberate
|
||||
* layout is never fought.
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Subagent tab type is enabled in settings,
|
||||
* open the panel (if collapsed) and focus the Subagent page
|
||||
* (single-instance: an existing tab is focused, never duplicated).
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
@@ -225,12 +233,36 @@ 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])
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
// Pin the landing to the right panel: the auto-opened Subagent page must
|
||||
// appear where the panel just expanded, not in a bottom-panel pane the
|
||||
// user last touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Task auto-activation: the moment a NEW background task appears for the
|
||||
* current conversation (a task id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Tasks tab type is enabled, open the panel
|
||||
* (if collapsed) and focus the Tasks page. Unlike the subagent trigger
|
||||
* (0 → N only), ANY new task id triggers: the agent may start several
|
||||
* tasks in one session, and each should surface. A fresh page load never
|
||||
* triggers — its baseline starts at the current snapshot.
|
||||
*/
|
||||
const taskBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = taskBaselineRef.current
|
||||
taskBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewTask(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenTasks) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
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,37 +280,158 @@ 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))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// Panel width drag (left edge strip).
|
||||
// The app shell's own columns: the bottom panel spans ONLY the center
|
||||
// column ("squeezes the agent output area") — it starts at the app
|
||||
// sidebar's right edge and ends at the details column's left edge (the
|
||||
// details column sits between the center and the right panel). Measured
|
||||
// from the AppFrame's DOM (the plugin already couples to #root in
|
||||
// layout.css); a missing frame degrades to 0 (full width).
|
||||
const [appSidebarWidth, setAppSidebarWidth] = useState(0)
|
||||
const [detailsWidth, setDetailsWidth] = useState(0)
|
||||
useEffect(() => {
|
||||
const frame = document.querySelector('#root > div')
|
||||
const sidebarCol = frame?.children[0] as HTMLElement | undefined
|
||||
const detailsCol = frame?.children[2] as HTMLElement | undefined
|
||||
if (sidebarCol === undefined || detailsCol === undefined) return
|
||||
const measure = (): void => {
|
||||
setAppSidebarWidth(sidebarCol.getBoundingClientRect().width)
|
||||
setDetailsWidth(detailsCol.getBoundingClientRect().width)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(sidebarCol)
|
||||
observer.observe(detailsCol)
|
||||
return () => { observer.disconnect() }
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
* expands the bottom panel in a session, try to open a fresh terminal tab
|
||||
* there. "Try" is literal — the terminal's own quota and enable switch
|
||||
* gate the attempt (a full quota or a disabled terminal type makes it a
|
||||
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
|
||||
* nested settings toggle, default on). Only a false→true TRANSITION fires
|
||||
* (a panel persisted open never counts as an expansion), and the session's
|
||||
* bottomOpenedOnce flag is set atomically with the first fire so later
|
||||
* expansions never repeat it.
|
||||
*/
|
||||
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
// The bottom panel does not exist on narrow viewports (the two
|
||||
// workbenches merge into one panel), so the first-expansion auto
|
||||
// terminal is a desktop-only behavior.
|
||||
if (narrow) return
|
||||
if (state === undefined) return
|
||||
const wasOpen = bottomWasOpenRef.current
|
||||
bottomWasOpenRef.current = state.bottomOpen
|
||||
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
|
||||
if (state.bottomOpenedOnce) return
|
||||
if (store.getPrefs().bottomPanelAutoTerminal === false) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
|
||||
// Land the tab in the bottom panel's first pane; the once-flag is set
|
||||
// atomically so later expansions never repeat the auto-open.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
|
||||
ctx.betterSidebar?.openTab({ type: 'terminal' })
|
||||
}, [state, store, ctx, narrow])
|
||||
|
||||
// Panel drags: the right panel's width (left edge strip), the bottom
|
||||
// panel's height (top edge strip), and the shared corner (both at once).
|
||||
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
|
||||
// variables) instead of round-tripping the store on every pointer move —
|
||||
// a store reduce re-renders both workbenches (terminals, editors…) per
|
||||
// move, which is the visible drag lag. The store is committed once on
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const cornerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||
const [draggingBottom, setDraggingBottom] = useState(false)
|
||||
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
|
||||
const [draggingCorner, setDraggingCorner] = useState(false)
|
||||
const anyDragging = draggingWidth || draggingBottom || draggingCorner
|
||||
|
||||
// Layout push: the app shell gives up the panel's width while it is open
|
||||
// (0 while collapsed), so the conversation and input bar are squeezed
|
||||
// instead of covered. The margin is capped at the viewport so a stale
|
||||
// persisted width (e.g. fullscreen on a bigger window) can never crush
|
||||
// the app shell to zero. Dragging disables the layout transition.
|
||||
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
||||
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
||||
const clampWidth = (width: number): number =>
|
||||
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
|
||||
const clampHeight = (height: number): number =>
|
||||
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
|
||||
|
||||
/** Apply a drag size to the DOM without touching React state or the store.
|
||||
* The bottom panel's right edge tracks the right panel's left edge HERE
|
||||
* too — React state only updates on release, so the inline right must be
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag. */
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
bottomRef.current?.style.setProperty('right', `${width + detailsWidth}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
if (cornerRef.current !== null) {
|
||||
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
|
||||
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
// faster than the display refresh, and each write reflows the app shell
|
||||
// (the layout push) plus the panels — batching to one write per frame is
|
||||
// what keeps the drag smooth. The store is still committed once on release.
|
||||
const dragFrame = useRef<number | null>(null)
|
||||
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
|
||||
const scheduleDrag = (width: number, height: number): void => {
|
||||
pendingDrag.current = { width, height }
|
||||
if (dragFrame.current !== null) return
|
||||
dragFrame.current = requestAnimationFrame(() => {
|
||||
dragFrame.current = null
|
||||
const pending = pendingDrag.current
|
||||
if (pending !== null) {
|
||||
pendingDrag.current = null
|
||||
applyDrag(pending.width, pending.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Flush any pending drag write and stop scheduling (the store commit on
|
||||
* pointer up applies the final clamped values). */
|
||||
const stopDragScheduling = (): void => {
|
||||
if (dragFrame.current !== null) {
|
||||
cancelAnimationFrame(dragFrame.current)
|
||||
dragFrame.current = null
|
||||
}
|
||||
pendingDrag.current = null
|
||||
}
|
||||
|
||||
// Layout push: the app shell gives up the panel's width/height while the
|
||||
// panels are open (0 while collapsed), so the conversation and input bar
|
||||
// are squeezed instead of covered. The margins are capped at the viewport
|
||||
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
|
||||
// crush the app shell to zero. Dragging disables the layout transition.
|
||||
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
||||
// conversation keeps the full width behind the drawer.
|
||||
useEffect(() => {
|
||||
const width = snapshot.state?.panelOpen === true
|
||||
const width = !narrow && snapshot.state?.panelOpen === true
|
||||
? Math.min(snapshot.state.width, window.innerWidth)
|
||||
: 0
|
||||
const height = !narrow && snapshot.state?.bottomOpen === true
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
}, [snapshot.state?.panelOpen, snapshot.state?.width])
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
useEffect(() => {
|
||||
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
||||
}, [draggingWidth])
|
||||
}, [anyDragging])
|
||||
|
||||
|
||||
// Whether the panel currently fills the viewport (fullscreen expansion).
|
||||
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
|
||||
|
||||
const actions: WorkbenchActions = useMemo(() => ({
|
||||
closeTab: (paneId, tabId) => {
|
||||
// A closed terminal releases its pty immediately — including when its
|
||||
@@ -325,7 +478,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
})
|
||||
},
|
||||
resizeSplit: (splitId, index, deltaFrac) => {
|
||||
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
|
||||
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
|
||||
@@ -356,10 +509,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div className={css.toggleRail}>
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelLeftOutline16 size={16} />
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -367,30 +527,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,6 +540,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
/** The tab icon from the tab-type registry (shared by every workbench). */
|
||||
const tabIconOf = (tab: SidebarTab): ReactNode => {
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) return null
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one tab's content. `active` (from the workbench) tells whether
|
||||
* this tab is the active one in its pane; combined with the panel's
|
||||
@@ -406,7 +554,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* polling while the page is not actually visible). The pane id travels
|
||||
* with the tab so diff tabs can split below their source pane.
|
||||
*/
|
||||
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
|
||||
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
sessionId={sessionId}
|
||||
@@ -416,7 +564,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onReferenceFile={referenceInChat}
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
visible={state.panelOpen && active}
|
||||
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
|
||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
||||
/>
|
||||
@@ -424,87 +572,216 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!state.panelOpen && (
|
||||
<div className={css.toggleRail}>
|
||||
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
Always pinned to the viewport corner — inside the right panel's
|
||||
top-right while it is open, sitting flush in the tab strip whose
|
||||
right end it really squeezes (the strip reserves its width via CSS),
|
||||
so the tabs genuinely yield space to it.
|
||||
*/}
|
||||
<div className={css.toggleCluster}>
|
||||
{/*
|
||||
Narrow viewports merge the two workbenches into the one drawer —
|
||||
there is no bottom panel, so its toggle button is not offered.
|
||||
*/}
|
||||
{!narrow && (
|
||||
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
|
||||
onClick={() => { store.reduce(toggleBottomPanel) }}
|
||||
>
|
||||
<IconPanelLeftOutline16 size={16} />
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/*
|
||||
The panel stays mounted while collapsed (hidden off-screen) so the
|
||||
slide in/out can animate; visibility hides it after the slide settles.
|
||||
The right panel stays mounted while collapsed (hidden off-screen) so
|
||||
the slide in/out can animate; visibility hides it after the slide
|
||||
settles. Its bottom edge follows the bottom panel's height (0 while
|
||||
the bottom panel is closed) — the VSCode-style "sidebar above panel".
|
||||
On NARROW viewports it is a full-width drawer holding both
|
||||
workbenches (see MobileWorkbench); the width drag strip is not
|
||||
offered there — a full-screen sheet has nothing to drag.
|
||||
*/}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: Math.min(state.width, window.innerWidth) }}
|
||||
data-dragging={draggingWidth || undefined}
|
||||
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
|
||||
data-dragging={anyDragging || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
||||
setDraggingWidth(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
/>
|
||||
<div className={css.panelHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
||||
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
||||
onClick={() => {
|
||||
const viewport = window.innerWidth
|
||||
store.reduce(s => setWidth(s, fullscreen
|
||||
? Math.max(280, Math.round((viewport - 320) / 2))
|
||||
: viewport))
|
||||
{!narrow && (
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
||||
setDraggingWidth(true)
|
||||
}}
|
||||
>
|
||||
<IconFullscreenOutline16 />
|
||||
</button>
|
||||
<span className={css.panelTitle}>
|
||||
{t('explorer')}
|
||||
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('collapse')}
|
||||
title={t('collapse')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state)}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={tabIconOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
The bottom panel: a second, independent workbench. It squeezes ONLY
|
||||
the center column (the agent output area): it starts at the app
|
||||
shell's own left sidebar and ends at the right panel's left edge —
|
||||
neither sidebar gives up any position (the right panel keeps its
|
||||
full height). Its resize strip is the top edge; hidden by sliding
|
||||
down like the right panel. On NARROW viewports it does not exist —
|
||||
the bottom workbench lives inside the drawer (MobileWorkbench).
|
||||
*/}
|
||||
{!narrow && (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: appSidebarWidth,
|
||||
// Ends at the details column's left edge (the details column sits
|
||||
// between the center and the right panel) plus the right panel's
|
||||
// own width — the bottom panel squeezes ONLY the center column.
|
||||
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0),
|
||||
// The seam against the open right panel needs its own hairline
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
}}
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
|
||||
setDraggingBottom(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(Math.min(state.width, window.innerWidth), height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
The bottom panel's own close control at its tab strip's right end
|
||||
(the strip reserves the width via CSS so the + menu never hides
|
||||
under it): one tap collapses the panel.
|
||||
*/}
|
||||
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.bottomClose}
|
||||
aria-label={t('collapseBottomPanel')}
|
||||
onClick={() => { store.reduce(toggleBottomPanel) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
tree={state.bottomSplits}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
|
||||
getTabIcon={tabIconOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
The shared corner (only while BOTH panels are open): the intersection
|
||||
of the right panel's left edge and the bottom panel's top edge.
|
||||
Horizontal drags resize the right panel's width, vertical drags the
|
||||
bottom panel's height — the two panels drag against each other.
|
||||
(Never on narrow viewports: the bottom panel does not exist there.)
|
||||
*/}
|
||||
{!narrow && state.panelOpen && state.bottomOpen && (
|
||||
<div
|
||||
ref={cornerRef}
|
||||
className={css.cornerHandle}
|
||||
style={{
|
||||
left: window.innerWidth - state.width - 6,
|
||||
top: window.innerHeight - state.bottomHeight - 6,
|
||||
}}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
cornerDrag.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: state.width,
|
||||
startHeight: state.bottomHeight,
|
||||
}
|
||||
setDraggingCorner(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -273,3 +273,275 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-task section (below the topology tree) ────────────────── */
|
||||
|
||||
.tasks {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tasksHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tasksTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* One task row; the whole row lights up on hover, the expandable target
|
||||
stays transparent (the kill button rides on the row's right edge). */
|
||||
.tasksRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tasksRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.tasksRowSelected,
|
||||
.tasksRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.tasksRowMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tasksRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tasksContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* The command line: a kind badge followed by the command itself in the code
|
||||
face, like the terminal's live lines. */
|
||||
.tasksLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasksKind {
|
||||
flex: none;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
line-height: 14px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksKill {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksKill:hover {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
|
||||
.tasksKillArmed,
|
||||
.tasksKillArmed:hover {
|
||||
width: auto;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tasksKillError {
|
||||
flex: none;
|
||||
margin-right: 4px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
|
||||
|
||||
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
|
||||
edge while the task list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.tasksPane {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasksPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.tasksPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tasksPaneLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksPanePre {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tasksPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
+342
-60
@@ -27,11 +27,13 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarTaskView,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
@@ -39,6 +41,17 @@ import {
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import {
|
||||
collectTreeTasks,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
type TreeTask,
|
||||
} from './subagent-tasks.ts'
|
||||
import { api, type TaskOutputResult } from './api.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
|
||||
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
/** Refresh cadence of an expanded task-output panel while its task runs. */
|
||||
const TASK_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const TASK_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
@@ -340,6 +357,262 @@ function CatalogRows({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared output dock of the tasks section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as
|
||||
* the MODEL has read it so far (replayed from the owner session's event
|
||||
* log), refreshed every {@link TASK_POLL_MS} while the task runs and the
|
||||
* page is visible. The model's `task_output` cursor is never touched — the
|
||||
* pane can never steal the agent's bytes, and it stays empty until the
|
||||
* agent reads the task. A single dock — not a panel per row — keeps the
|
||||
* task list compact and stable when many tasks are running.
|
||||
*/
|
||||
function TaskOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
task: SidebarTaskView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { ownerSessionId, task, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading')
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal)
|
||||
setState(result)
|
||||
} catch {
|
||||
// A newer pull aborted this one, or the wire failed: keep the last
|
||||
// known output; only a dock that never loaded anything shows an error.
|
||||
setState(current => (current === 'loading' ? 'error' : current))
|
||||
}
|
||||
}, [ownerSessionId, task.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isTaskLive(task)) return
|
||||
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, task.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the task runs, each refresh pins the view
|
||||
// to the newest output; a settled dock leaves scrolling to the reader.
|
||||
useEffect(() => {
|
||||
if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, task.status])
|
||||
|
||||
return (
|
||||
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}>
|
||||
<div className={css.tasksPaneHeader}>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} />
|
||||
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span>
|
||||
<span className={css.tasksPaneStatus}>
|
||||
{taskStatusLabel(task.status, t)}
|
||||
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-task section of the Subagent page: every task of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `session/tasks` push mirror. Clicking a row feeds its model-read output to
|
||||
* the shared bottom dock (event replay — never the model's cursor); live
|
||||
* rows carry a two-click-confirm kill button. Renders nothing while the
|
||||
* tree has no tasks.
|
||||
*/
|
||||
function TasksSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
tasksBySession: SidebarSessionList['tasksBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, tasksBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)),
|
||||
[byId, tasksBySession, rootId],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(undefined)
|
||||
const [killingId, setKillingId] = useState<string | undefined>(undefined)
|
||||
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
|
||||
// The duration clock only runs while a live row is on screen.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0),
|
||||
[rows],
|
||||
)
|
||||
const multiOwner = useMemo(
|
||||
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
|
||||
[rows],
|
||||
)
|
||||
|
||||
// The kill button stays armed only briefly; a stray click must never kill.
|
||||
useEffect(() => {
|
||||
if (armedId === undefined) return
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveCount === 0) return
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [liveCount])
|
||||
|
||||
// The docked output pane follows its task: when the selected task leaves
|
||||
// the mirror (settled and dropped, or the tree switched), close the dock.
|
||||
useEffect(() => {
|
||||
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
|
||||
}, [selectedId, selectedRow])
|
||||
|
||||
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
|
||||
// would flip this component's hook count when the mirror empties and crash
|
||||
// React with "Rendered fewer hooks than expected" (the #300 regression).
|
||||
const kill = useCallback(async (row: TreeTask): Promise<void> => {
|
||||
setKillingId(row.task.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id)
|
||||
} catch {
|
||||
setKillErrorId(row.task.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('tasksCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('tasksCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={css.tasks} aria-label={t('tasks')}>
|
||||
<div className={css.tasksHeader}>
|
||||
<span className={css.tasksTitle}>{t('tasks')}</span>
|
||||
<span className={css.tasksCount}>{countLabel}</span>
|
||||
</div>
|
||||
<ul className={css.tasksList} aria-label={t('tasks')}>
|
||||
{rows.map((row) => {
|
||||
const { task } = row
|
||||
const live = isTaskLive(task)
|
||||
const selected = selectedId === task.id
|
||||
const armed = armedId === task.id
|
||||
const killing = killingId === task.id
|
||||
const killFailed = killErrorId === task.id
|
||||
const elapsed = live
|
||||
? now - task.startedAt
|
||||
: (task.finishedAt ?? task.startedAt) - task.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
taskStatusLabel(task.status, t),
|
||||
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []),
|
||||
formatTaskDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className={clsx(
|
||||
css.tasksRow,
|
||||
!live && css.tasksRowSettled,
|
||||
selected && css.tasksRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${task.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : task.id) }}
|
||||
>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksDot} />
|
||||
<span className={css.tasksContent}>
|
||||
<span className={css.tasksLabelLine}>
|
||||
<span className={css.tasksKind}>{task.kind}</span>
|
||||
<span className={css.tasksLabel} title={task.label}>{task.label}</span>
|
||||
</span>
|
||||
<span className={css.tasksSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{task.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill}
|
||||
aria-label={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
title={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(task.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<TaskOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
task={selectedRow.task}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
@@ -515,69 +788,78 @@ export function SubagentView(props: {
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TasksSection
|
||||
byId={byId}
|
||||
tasksBySession={list.tasksBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
</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"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
|
||||
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
|
||||
* image viewing through the media route, and a plain notice for binary
|
||||
* files. Markdown files open in rendered preview with a preview/edit toggle:
|
||||
* edit mode shows the source in the editor, preview renders the live draft,
|
||||
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
|
||||
* (a banner marks truncation).
|
||||
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
|
||||
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
|
||||
* save, and a preview/edit toggle for markdown files. Registered as the
|
||||
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
|
||||
* fetches the content through the fsRead strategy and passes it in props,
|
||||
* so this component never fetches or dispatches — it only edits.
|
||||
*
|
||||
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
||||
* row below the host's title bar, VSCode-style.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -13,33 +15,29 @@ import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
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 { FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
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: '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
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
* could read session data) and NO allow-top-navigation (a previewed page
|
||||
* must not hijack the GUI). The user can disable the sandbox per-feature
|
||||
* in the side card settings (warned); the toggle below reflects it.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
@@ -55,71 +53,29 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
// A new file (tab switch) starts clean: fresh preview mode, no draft.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
setMode('preview')
|
||||
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
|
||||
}
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
kind: MD_EXT.includes(ext) ? 'md' : 'text',
|
||||
content: result.content,
|
||||
truncated: result.truncated,
|
||||
})
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the file is loaded. The view owns the
|
||||
// document; React only tracks dirty/draft state through the update
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty/draft state through the update
|
||||
// listener. For markdown the view stays mounted while previewing (hidden),
|
||||
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
|
||||
if (content === undefined) return
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const language = languageForPath(path)
|
||||
const themeComp = new CmThemeCompartment()
|
||||
themeCompRef.current = themeComp
|
||||
const state = EditorState.create({
|
||||
doc: load.content,
|
||||
doc: content,
|
||||
extensions: [
|
||||
CodeMirrorView.lineWrapping,
|
||||
lineNumbers(),
|
||||
@@ -156,7 +112,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
}, [load])
|
||||
}, [content, path])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
// scheme-dependent extensions; everything else is untouched).
|
||||
@@ -189,15 +145,24 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
})
|
||||
}
|
||||
|
||||
const previewable = load.status === 'ready' && load.kind === 'md'
|
||||
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
||||
// plus a per-surface temporary unlock. The unlock state starts at the
|
||||
// "default unsafe" pref so a preview can open straight into the red
|
||||
// unsandboxed state (still restorable from the status row). With the
|
||||
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
|
||||
// the previewed page then runs on the GUI's own origin with full session
|
||||
// access.
|
||||
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
||||
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
{previewable && (
|
||||
{(markdown || html) && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -229,55 +194,43 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
|
||||
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'image' && (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
{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} />
|
||||
)}
|
||||
{previewable && mode === 'preview' && (
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content} />
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{html && mode === 'preview' && (
|
||||
<>
|
||||
<SandboxStatusBar
|
||||
sandboxed={!htmlNoSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('htmlNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
||||
origin when unsandboxed; the route URL keeps the frame
|
||||
cross-origin by construction). The preview shows the SAVED
|
||||
file; the draft is only visible in edit mode. */}
|
||||
<iframe
|
||||
className={css.editorHtml}
|
||||
src={htmlUrl(scope, path)}
|
||||
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={path}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
+44
-2
@@ -6,6 +6,8 @@
|
||||
* hydrating at page load (a detached session would otherwise fail the
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
export class SidebarApiError extends Error {
|
||||
@@ -54,8 +56,24 @@ export interface GitLogEntry {
|
||||
|
||||
/** Text read result. */
|
||||
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
|
||||
/** Binary read result (no content; images load through the media route). */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean }
|
||||
/** Binary read result (no content; images load through the media route).
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One tasks.output response: the output the MODEL has read so far for the
|
||||
* task (replayed from the owner session's event log — the model's
|
||||
* task_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* task_output for the task.
|
||||
*/
|
||||
export interface TaskOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the task at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
@@ -142,6 +160,19 @@ export const api = {
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/**
|
||||
* The output the model has read so far for one background task (replayed
|
||||
* from the owner session's event log — never the model's task_output
|
||||
* cursor). The scope MUST be the task's OWNER session.
|
||||
*/
|
||||
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background task (live tasks flip to stopping). */
|
||||
taskKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
@@ -151,6 +182,10 @@ export const api = {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
/** Probe a URL's response headers (the sidebar browser's embeddability
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
@@ -171,3 +206,10 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
if (download) params.set('download', '1')
|
||||
return `/sidebar/file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
|
||||
* fully encoded so the previewed page's relative assets resolve back into
|
||||
* the same route with the session scope intact. */
|
||||
export function htmlUrl(scope: SessionScope, path: string): string {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The "no preview — download instead" pane: shown by the editor host when no
|
||||
* viewer can render a file (binary without a registered renderer, or the
|
||||
* `binary-download` strategy) and registered as the `binary-download` viewer
|
||||
* component so the declarative route and the host fallback share one UI.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { downloadUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
|
||||
const { scope, path } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
|
||||
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
|
||||
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
|
||||
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
|
||||
*
|
||||
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
|
||||
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
|
||||
* tablet experience. The value is deliberately NOT aligned to the DSH app
|
||||
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
|
||||
* panes) keep the desktop two-panel layout.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
|
||||
export const NARROW_MAX_WIDTH = 768
|
||||
|
||||
/** Whether a viewport width is narrow (mobile). */
|
||||
export function isNarrowWidth(width: number): boolean {
|
||||
return width < NARROW_MAX_WIDTH
|
||||
}
|
||||
|
||||
/**
|
||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
||||
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
|
||||
* resize listener is equally exact for a breakpoint that never changes
|
||||
* while the page is open.
|
||||
*/
|
||||
export function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
|
||||
)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
setNarrow(isNarrowWidth(window.innerWidth))
|
||||
}
|
||||
const onResize = (): void => {
|
||||
if (frame === null) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
return narrow
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Pure URL policy for the built-in browser tab: normalize user input into
|
||||
* an http(s) URL, and refuse destinations that would be dangerous to embed
|
||||
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
|
||||
*
|
||||
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
|
||||
* is the primary security boundary; this module is the address-bar gate on
|
||||
* top of it: only http/https may be navigated, and loopback addresses are
|
||||
* refused so a browsed page cannot probe local services by user action.
|
||||
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
|
||||
* itself in the sidebar (debugging, mirroring); the sandbox still renders
|
||||
* it in an opaque origin with no same-origin privileges, exactly like any
|
||||
* other site.
|
||||
*/
|
||||
|
||||
/** Why a navigation attempt was refused. */
|
||||
export type BrowserBlockReason = 'scheme' | 'loopback'
|
||||
|
||||
/** Result of normalizing one address-bar input. */
|
||||
export type BrowserNavigateResult =
|
||||
| { kind: 'ok'; url: string }
|
||||
| { kind: 'blocked'; reason: BrowserBlockReason }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/** One browser.probe wire result (host fetch of the target's headers). */
|
||||
export interface BrowserProbeResult {
|
||||
reachable: boolean
|
||||
/** The final (post-redirect) URL; present when reachable. */
|
||||
url?: string
|
||||
status?: number
|
||||
xFrameOptions?: string
|
||||
/** The CSP frame-ancestors source list; present when the directive exists. */
|
||||
frameAncestors?: string[]
|
||||
}
|
||||
|
||||
/** Embeddability verdict of one probe. */
|
||||
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
|
||||
|
||||
/**
|
||||
* Decide whether a site can render inside the sidebar iframe. The signals
|
||||
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
|
||||
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
|
||||
* not allow `*` ('self' here means the SITE's own origin — never ours, so
|
||||
* it also blocks the sidebar). A site we could not reach yields 'unknown'
|
||||
* and the plain iframe stays.
|
||||
*/
|
||||
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
|
||||
if (probe.reachable !== true) return 'unknown'
|
||||
const xfo = probe.xFrameOptions?.trim().toUpperCase()
|
||||
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
|
||||
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
|
||||
return 'embeddable'
|
||||
}
|
||||
|
||||
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
|
||||
const parts = host.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one address-bar input against the navigation policy.
|
||||
* @param input - raw user text.
|
||||
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
|
||||
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
|
||||
* is let through BEFORE the loopback check — its host is normally loopback.
|
||||
*/
|
||||
/** Schemes that must never reach the iframe, even without `//` (javascript:,
|
||||
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
||||
* they parse as hosts below. */
|
||||
const FORBIDDEN_SCHEMES = new Set([
|
||||
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
|
||||
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
|
||||
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
|
||||
])
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '') return { kind: 'invalid' }
|
||||
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
||||
// would match a naive scheme regex (dots are legal in schemes), so a
|
||||
// scheme prefix is only honored when it is http(s) or a known-forbidden
|
||||
// scheme; anything else is treated as a host and gets https://.
|
||||
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
||||
let withScheme: string
|
||||
if (schemeMatch === null) {
|
||||
withScheme = `https://${trimmed}`
|
||||
} else {
|
||||
const scheme = schemeMatch[1]!.toLowerCase()
|
||||
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
|
||||
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
|
||||
else withScheme = `https://${trimmed}`
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
// The protocol backstop: any URL that still parses to a non-http(s)
|
||||
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
|
||||
// refused here.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
|
||||
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
|
||||
// the sidebar; the sandbox renders it in an opaque origin like any other
|
||||
// site). It must be checked before the loopback gate because its host is
|
||||
// normally loopback.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
|
||||
}
|
||||
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Built-in registration: the plugin registers its own tab pages and file
|
||||
* previewers through the same {@link BetterSidebarService} external plugins
|
||||
* use — eating its own dogfood. The descriptors live next to their feature
|
||||
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
|
||||
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
|
||||
* HMR-safe).
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import { builtinTabs } from './tabs.tsx'
|
||||
import { builtinViewers } from './viewers.tsx'
|
||||
|
||||
/**
|
||||
* Register all built-in tabs and viewers with the service. Returns a
|
||||
* disposer that unregisters everything (cordis auto-invokes it on fiber
|
||||
* disposal). The `ctx` is threaded into tab descriptors that need it
|
||||
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
|
||||
*/
|
||||
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of builtinTabs(ctx)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
for (const viewer of builtinViewers()) {
|
||||
disposers.push(service.registerFileViewer(viewer))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<n>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota).
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/**
|
||||
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
|
||||
* only when a terminal tab is first opened (see chunk-loader.ts). The
|
||||
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
|
||||
* calls it as a plain function.
|
||||
*
|
||||
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
|
||||
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
|
||||
* descriptor maps it explicitly; a bare pass-through would leave tabId
|
||||
* undefined and TerminalView's isAgentTabId(tabId) would crash on
|
||||
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
|
||||
*/
|
||||
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
|
||||
'terminal',
|
||||
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
|
||||
)
|
||||
|
||||
/** The terminal view's props (mirror of TerminalView's own signature). */
|
||||
interface TerminalViewProps {
|
||||
scope: SessionScope
|
||||
tabId: string
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 7 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Tasks page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenTasks',
|
||||
title: () => t('settingsTasksTitle'),
|
||||
desc: () => t('settingsTasksDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch and the
|
||||
// bottom-panel first-expansion auto-terminal switch render under this
|
||||
// row in the Side card settings page (the Terminal page's own related
|
||||
// settings; the host gates the toolset on the tools one independently).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
title: () => t('settingsToolsTitle'),
|
||||
desc: () => t('settingsToolsDesc'),
|
||||
}, {
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}],
|
||||
},
|
||||
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: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch and the link
|
||||
// takeover render under this tab's row in the Side card settings
|
||||
// page (the sandbox one is warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
title: () => t('settingsBrowserSandboxTitle'),
|
||||
desc: () => t('settingsBrowserSandboxDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptLinks',
|
||||
title: () => t('settingsBrowserLinksTitle'),
|
||||
desc: () => t('settingsBrowserLinksDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
tab: {
|
||||
id: `browser:${state.nextBrowser}`,
|
||||
type: 'browser',
|
||||
title: t('browser'),
|
||||
},
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: (props) => <BrowserView {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: ({ scope, tab }) => (
|
||||
tab.diff === undefined ? null
|
||||
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The 9 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
|
||||
* code / binary-download), exactly like external plugins register theirs.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*
|
||||
* The heavy viewers (docx/xlsx/pptx and the CodeMirror-backed
|
||||
* markdown/html/code) render through {@link lazyChunkComponent} wrappers —
|
||||
* their libraries are fetched only when such a file is first opened (see
|
||||
* chunk-loader.ts). The descriptor metadata (id/exts/priority/detect) is
|
||||
* identical either way, so matching semantics and external-plugin
|
||||
* overrides are unaffected; the `component` wrapper keeps the descriptor
|
||||
* contract `(props) => ReactNode`.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Lazy wrappers over the chunk-resident viewer components. The `pick`
|
||||
* functions are module-level (stable identity — the wrapper effect depends
|
||||
* on them); the cast bridges the chunk exports record to the descriptor
|
||||
* prop shape (the views read only their own subset of FileViewerProps).
|
||||
*/
|
||||
const LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
|
||||
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
title: () => t('viewerImage'),
|
||||
icon: (size: number) => <IconImageOutline16 size={size} />,
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={url} alt={title} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: () => t('viewerPdf'),
|
||||
icon: (size: number) => <IconPdfOutline16 size={size} />,
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyDocx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyXlsx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyPptx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
title: () => t('viewerHtml'),
|
||||
icon: (size: number) => <IconHtmlOutline16 size={size} />,
|
||||
exts: ['html', 'htm'],
|
||||
fetchStrategy: 'fsRead',
|
||||
// Declarative settings: the sandbox escape hatch and the default-unsafe
|
||||
// start state render under this viewer's row in the Side card settings
|
||||
// page (both warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'htmlViewerNoSandbox',
|
||||
title: () => t('settingsHtmlSandboxTitle'),
|
||||
desc: () => t('settingsHtmlSandboxDesc'),
|
||||
}, {
|
||||
key: 'htmlViewerDefaultUnsafe',
|
||||
title: () => t('settingsHtmlDefaultUnsafeTitle'),
|
||||
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
|
||||
}],
|
||||
},
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
|
||||
// before the catch-all code viewer on the head re-match.
|
||||
detect: (_path, head) => head.includes(0),
|
||||
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
||||
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
|
||||
* office/terminal/editor stacks, tens of MB) live in separate build-time
|
||||
* bundles (`lib/client-<name>.js`) fetched only on first use of the feature
|
||||
* that needs them, so startup downloads/parses only the ~1MB core bundle.
|
||||
*
|
||||
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
||||
*
|
||||
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
|
||||
*
|
||||
* The script registers its factory on a plugin-owned global registry (NOT
|
||||
* through window.__ModuleLoader__.load — the module loader's import() only
|
||||
* resolves seed words, shell-own modules, registered factories, and boot
|
||||
* graph rows; a chunk id is none of those, so resolution would be version-
|
||||
* dependent). Materialization is plugin-owned:
|
||||
*
|
||||
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
|
||||
* script; the official /plugins/<id>/client.js route cannot serve
|
||||
* arbitrary file names, so the plugin's own host route serves the chunks),
|
||||
* 2. read the factory from the global registry,
|
||||
* 3. call it with a require that resolves the platform externals through
|
||||
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
||||
* the module system that is stable across versions.
|
||||
*
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
* {@link resetChunks}; a failed load removes its entry so the next call
|
||||
* retries from scratch.
|
||||
* - Script execution: each re-execution overwrites the global registry slot
|
||||
* (assignment, never registration) — no "duplicate factory registration"
|
||||
* class of errors; a failed materialization clears the cache so the retry
|
||||
* re-injects and re-executes.
|
||||
* - HTTP: the bundle route revalidates every request (`cache-control:
|
||||
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
||||
* re-activations never re-download a multi-MB chunk that did not change.
|
||||
*
|
||||
* HMR: each plugin activation calls {@link resetChunks}, which drops the
|
||||
* in-memory cache (and any test registry), so a hot-reloaded core bundle
|
||||
* re-fetches and re-executes the current chunk scripts on the next lazy
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
*/
|
||||
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
|
||||
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
|
||||
/**
|
||||
* The platform externals a chunk bundle may require (mirror of
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
||||
|
||||
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
|
||||
interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (set before any plugin activates). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
interface ChunkRegistry {
|
||||
[name: string]: ChunkFactory | undefined
|
||||
}
|
||||
|
||||
function chunkRegistry(): ChunkRegistry {
|
||||
const g = globalThis as { __dshChunks__?: ChunkRegistry }
|
||||
return g.__dshChunks__ ??= {}
|
||||
}
|
||||
|
||||
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
|
||||
export type ChunkScriptLoader = (src: string) => Promise<void>
|
||||
|
||||
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.async = true
|
||||
el.src = src
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
|
||||
|
||||
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
|
||||
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
|
||||
scriptLoader = loader ?? defaultScriptLoader
|
||||
}
|
||||
|
||||
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
|
||||
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
|
||||
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
|
||||
testLoaders.set(name, loader)
|
||||
}
|
||||
|
||||
/** Memoized externals require, resolved once per page from the seed table. */
|
||||
let externalsRequire: ((spec: string) => unknown) | undefined
|
||||
|
||||
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
|
||||
if (externalsRequire !== undefined) return externalsRequire
|
||||
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
|
||||
// the runtime/client exemption row) stays unresolved until a chunk
|
||||
// actually requires it — only then it is a loud error.
|
||||
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
|
||||
try {
|
||||
return [spec, await modules.import(spec)] as const
|
||||
} catch {
|
||||
return [spec, undefined] as const
|
||||
}
|
||||
}))
|
||||
const table = new Map<string, unknown>(entries)
|
||||
externalsRequire = (spec: string): unknown => {
|
||||
if (!table.has(spec)) {
|
||||
// Single quotes: the bundle-consistency scan regexes for require("...")
|
||||
// lexical calls — a double-quoted literal here would trip it.
|
||||
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
|
||||
}
|
||||
return table.get(spec)
|
||||
}
|
||||
return externalsRequire
|
||||
}
|
||||
|
||||
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
||||
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
|
||||
/**
|
||||
* Load (once) and materialize a lazy chunk, returning its module exports.
|
||||
* Concurrent callers share one in-flight load; a failure clears the cache
|
||||
* entry so the next call retries (the script re-executes and overwrites its
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
const task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
if (modules === undefined) {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
|
||||
}
|
||||
await scriptLoader(CHUNK_URL(name))
|
||||
const factory = chunkRegistry()[name]
|
||||
if (typeof factory !== 'function') {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
return factory(require)
|
||||
})()
|
||||
cache.set(name, task)
|
||||
void task.catch(() => { cache.delete(name) })
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
||||
* in-memory cache and any test-registry entries, so the next lazy open
|
||||
* re-fetches and re-executes the current chunk scripts (the registry slots
|
||||
* are overwritten by the re-execution — no cleanup needed).
|
||||
*/
|
||||
export function resetChunks(): void {
|
||||
cache.clear()
|
||||
testLoaders.clear()
|
||||
externalsRequire = undefined
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
|
||||
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
|
||||
* fetched only when a .docx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the office library into the startup path.
|
||||
*/
|
||||
export { DocxView } from '../docx-view.tsx'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
|
||||
* language packages). Built as `lib/client-editor.js` and registered under
|
||||
* `dsh-better-sidebar/editor` — fetched only when a text file is first
|
||||
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls CodeMirror into
|
||||
* the startup path.
|
||||
*/
|
||||
export { TextEditor } from '../TextEditor.tsx'
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
|
||||
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
|
||||
* fetched only when a .pptx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the renderer into the startup path.
|
||||
*/
|
||||
export { PptxView } from '../PptxView.tsx'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
|
||||
* `lib/client-terminal.js` and registered under
|
||||
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
|
||||
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls xterm (and its
|
||||
* stylesheet) into the startup path.
|
||||
*/
|
||||
export { TerminalView } from '../TerminalView.tsx'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
|
||||
* Built as `lib/client-xlsx.js` and registered under
|
||||
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx is first opened
|
||||
* (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls Univer into the
|
||||
* startup path.
|
||||
*/
|
||||
export { XlsxView } from '../xlsx-view.tsx'
|
||||
@@ -5,7 +5,7 @@
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* EditorView), so the document, undo history and scroll survive re-theming.
|
||||
* TextEditor), so the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The .docx preview component: renders via docx-preview (preserved
|
||||
* styles/images/tables). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
|
||||
* opened, and never pulls in the Univer/xlsx stack.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pure editor-load planning: the decision logic the editor host needs to
|
||||
* turn a matched file viewer + a host fs.read result into a render action.
|
||||
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
|
||||
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
|
||||
*
|
||||
* The host flow this module drives:
|
||||
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
|
||||
* 2. `planFirstMatch` dispatches its fetchStrategy.
|
||||
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
|
||||
* decides what to do with the result — including the head-based
|
||||
* re-match that lets a `detect` viewer claim a binary the extension
|
||||
* match could not see (the builtin NUL probe on `binary-download`).
|
||||
*/
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
|
||||
/** One host fs.read result (mirror of the wire; `head` present when binary). */
|
||||
export interface FsReadOutcome {
|
||||
binary: boolean
|
||||
content: string
|
||||
truncated: boolean
|
||||
/** base64 of the first bytes (present on binary reads; sniffing material). */
|
||||
head?: string
|
||||
}
|
||||
|
||||
/** What the editor host should do next. */
|
||||
export type EditorLoadAction =
|
||||
/** No renderer: show the download UI. */
|
||||
| { kind: 'binary' }
|
||||
/** Render `viewer`'s component with the carried payload. */
|
||||
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
/** Fetch the file through the host (fsRead strategy). */
|
||||
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
|
||||
/** Call the viewer's load() and render with its return value. */
|
||||
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
|
||||
|
||||
/** Decode the host's base64 head bytes into the sniffing buffer. */
|
||||
export function decodeHead(headBase64: string): Uint8Array {
|
||||
const binary = atob(headBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
|
||||
* `binary-download` strategy both mean "no client-side renderer" → the
|
||||
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
|
||||
* strategies (pure, but scope-bound — injected by the host).
|
||||
*/
|
||||
export function planFirstMatch(
|
||||
viewer: FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl':
|
||||
case 'none':
|
||||
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
|
||||
case 'custom':
|
||||
return { kind: 'customLoad', viewer }
|
||||
case 'fsRead':
|
||||
return { kind: 'fetchFsRead', viewer }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what an fsRead result means for the editor.
|
||||
* - Text: the first match stands (content is valid for any fsRead viewer).
|
||||
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
|
||||
* a plugin sniffing a binary format) may claim the file. `custom` viewers
|
||||
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
|
||||
* an fsRead viewer or nothing cannot render binary → download UI.
|
||||
*/
|
||||
export function planFsReadOutcome(
|
||||
viewer: FileViewerDescriptor,
|
||||
result: FsReadOutcome,
|
||||
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (!result.binary) {
|
||||
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
|
||||
}
|
||||
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
|
||||
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
|
||||
return { kind: 'customLoad', viewer: claimed }
|
||||
}
|
||||
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
|
||||
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
|
||||
}
|
||||
return { kind: 'binary' }
|
||||
}
|
||||
+111
-18
@@ -1,14 +1,34 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none) and the per-tab-type icon mapping shared by the
|
||||
* tab strip, the + menu, and the empty-pane cards.
|
||||
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
|
||||
* the top-right cluster. Per-tab icons live on the tab descriptors
|
||||
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
|
||||
* registry-ized with the tab types.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TabType } from './state.ts'
|
||||
|
||||
/**
|
||||
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
|
||||
* along its RIGHT edge, in the app's outline style (1.5px stroke,
|
||||
* currentColor).
|
||||
*/
|
||||
export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
|
||||
* along its BOTTOM edge, in the app's outline style.
|
||||
*/
|
||||
export const IconPanelBottomOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="3.25" y="10" width="9.5" height="2.75" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
@@ -31,14 +51,87 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
|
||||
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
|
||||
switch (type) {
|
||||
case 'explorer': return <IconFolderOpen16 size={size} />
|
||||
case 'git': return <IconBranchOutline16 size={size} />
|
||||
case 'terminal': return <IconTerminalOutline16 size={size} />
|
||||
case 'subagent': return <IconThinkOutline16 size={size} />
|
||||
case 'editor': return <IconCodeOutline16 size={size} />
|
||||
case 'diff': return <IconDiffOutline16 size={size} />
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Stop glyph for the background-task kill button: a filled square in the
|
||||
* app's outline scale (16), the universal "halt this work" mark.
|
||||
*/
|
||||
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
export const IconImageOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PDF viewer glyph: a document frame with the "PDF" label. */
|
||||
export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** HTML viewer glyph: a document frame with a "‹/›" tag pair. */
|
||||
export const IconHtmlOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5.6 13.2 4.2 10l1.4-3.2M7.4 6.8 8.8 10l-1.4 3.2" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Browser tab glyph: a globe with meridians. */
|
||||
export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* IME-composition key guard.
|
||||
*
|
||||
* While a Chinese/Japanese/Korean input method is composing (the user is
|
||||
* picking a candidate from the IME window), every pressed key BELONGS to the
|
||||
* input method: arrows move the candidate highlight, Enter/Space confirm the
|
||||
* composition, Escape cancels it. Page code must not process those keys —
|
||||
* a component that does (a number stepper calling preventDefault() on
|
||||
* ArrowUp/ArrowDown, a submit handler reacting to Enter, ...) silently
|
||||
* breaks the IME: candidates stop responding, the composition gets torn
|
||||
* apart, and only bare letters come out.
|
||||
*
|
||||
* This guard enforces that rule at the document boundary: a capture-phase
|
||||
* keydown/keyup listener that stops the event from propagating further
|
||||
* whenever a composition is in progress. Because it runs in the capture
|
||||
* phase on `document` — the outermost node — it fires BEFORE React's
|
||||
* delegated handlers (attached at the root container) and before any native
|
||||
* target/bubble listener, so an inlined third-party component (e.g. the
|
||||
* Univer office UI bundled into this plugin) can never intercept
|
||||
* composition keys. The browser's native IME processing is untouched:
|
||||
* stopPropagation only silences page JS, not the default action.
|
||||
*
|
||||
* The composition signal follows the DSH core convention (InputBar's IME
|
||||
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as the
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level capture guard. Returns the disposer
|
||||
* (HMR-safe; call through `ctx.effect`).
|
||||
*/
|
||||
export function registerImeGuard(): () => void {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (isImeComposition(event)) event.stopPropagation()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
document.addEventListener('keyup', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
document.removeEventListener('keyup', onKey, true)
|
||||
}
|
||||
}
|
||||
+82
-4
@@ -12,8 +12,13 @@ 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 { resetChunks } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
@@ -22,7 +27,7 @@ import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions', 'connection']
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
@@ -69,6 +74,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.
|
||||
@@ -86,6 +104,10 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Fresh chunk state for this activation: invalidate any chunk factories
|
||||
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
||||
// so the next lazy open re-fetches the current chunk scripts.
|
||||
resetChunks()
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
@@ -131,16 +153,72 @@ export function apply(ctx: Context): void {
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerOpenPathInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: open-path interception',
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
// External http(s) links in the chat/GUI open the sidebar browser
|
||||
// instead of a new window (gated on the browserInterceptLinks pref
|
||||
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
||||
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
|
||||
openInSidebar: (url) => {
|
||||
let title: string | undefined
|
||||
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
||||
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: link interception',
|
||||
)
|
||||
|
||||
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
||||
// belong to the input method, never to page JS. Inlined third-party UI
|
||||
// (Univer's office controls) has shipped unguarded keydown handlers that
|
||||
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
||||
// document-capture guard neutralizes the whole class before React or any
|
||||
// native listener sees the event. Registered as early as possible so no
|
||||
// other capture-phase listener can win the ordering race.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerImeGuard()
|
||||
} catch (error) {
|
||||
fail('ime guard', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: IME composition guard',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
// fenced settings route, keeps the shared store in sync, and renders the
|
||||
// declarative enable/disable inventory from the tab/viewer registry.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
inject: () => ({ store: sidebarStore, service }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
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 { wrapOpenPath } from './openpath-intercept.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
||||
@@ -19,12 +20,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). */
|
||||
@@ -63,7 +61,13 @@ export function SidebarProducedFiles(props: {
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
// deliverables behavior instead of offering chips that cannot open.
|
||||
select: (owner) => {
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
@@ -71,3 +75,20 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
|
||||
* — the single funnel every chat-side file open goes through (tool-row path
|
||||
* links, the produced-files row, prose mentions) — so opens land in the
|
||||
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
|
||||
* pref and the editor tab's enable switch; declined opens fall through to
|
||||
* the original method. Returns the disposer restoring the original (HMR-safe).
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return wrapOpenPath(ctx.workspaces, {
|
||||
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
})
|
||||
}
|
||||
|
||||
+27
-11
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
|
||||
* Layout push: when a panel is open it OCCUPIES the layout instead of
|
||||
* floating over it — the app shell (#root, the AppFrame three-column grid)
|
||||
* gives up the panel's width on the right. Only the center column is
|
||||
* flexible (1fr), so the squeeze lands exactly on the conversation output
|
||||
* and the input bar, like a VSCode sidebar. The fixed panel covers the
|
||||
* vacated strip, which looks seamless because #root's background is the
|
||||
* theme base.
|
||||
* gives up space. Only the center column is flexible (1fr), so the right
|
||||
* panel's width squeeze (margin-right on #root) lands exactly on the
|
||||
* conversation output and the input bar, like a VSCode sidebar.
|
||||
*
|
||||
* The width rides a CSS variable updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margin and the panel slide
|
||||
* on the same theme duration. Width drags disable the transition so the
|
||||
* The bottom panel squeezes ONLY the center column — it must not cover the
|
||||
* app's own left sidebar or the right panel. The AppFrame's grid items are
|
||||
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so
|
||||
* the vertical push lands on the center column alone. The fixed panels
|
||||
* cover the vacated strips, which looks seamless because #root's background
|
||||
* is the theme base.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
* slides on the same theme duration. Drags disable the transition so the
|
||||
* layout tracks the pointer.
|
||||
*/
|
||||
#root {
|
||||
@@ -17,12 +22,23 @@
|
||||
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root {
|
||||
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
|
||||
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid
|
||||
item shrinks by its margins, so the conversation content (output + input
|
||||
bar) lifts without touching the sidebars. */
|
||||
#root > div > div:nth-child(2) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root {
|
||||
#root,
|
||||
#root > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
|
||||
* showing a loading placeholder while the chunk script loads and an error +
|
||||
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
|
||||
*
|
||||
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
|
||||
* function — the descriptor contract is `component: (props) => ReactNode`,
|
||||
* and the repo renders descriptors BOTH ways: Sidebar calls
|
||||
* `descriptor.component(props)` directly, EditorHost renders it via
|
||||
* `createElement`. The wrapper function body therefore contains no hooks;
|
||||
* all state lives in the inner {@link LazyChunkView} component.
|
||||
*/
|
||||
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LazyChunkViewProps<P> {
|
||||
chunk: ChunkName
|
||||
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined
|
||||
props: P
|
||||
}
|
||||
|
||||
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; Comp: ComponentType<any> }
|
||||
>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading' })
|
||||
loadChunk(chunk).then((mod) => {
|
||||
if (cancelled) return
|
||||
const Comp = pick(mod)
|
||||
if (Comp === undefined) {
|
||||
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
|
||||
return
|
||||
}
|
||||
setState({ status: 'ready', Comp })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [chunk, pick, attempt])
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return <div className={css.editorPlaceholder}>{t('loading')}</div>
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className={css.editorError}>
|
||||
<span>{state.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { setAttempt(current => current + 1) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return createElement(state.Comp, props)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
|
||||
* The returned function is the descriptor `component` itself: it returns an
|
||||
* element and never calls hooks, so both invocation styles (plain function
|
||||
* call and createElement/JSX render) work. `pick` must be a module-level
|
||||
* function (stable identity) — an inline lambda would re-trigger the load
|
||||
* effect on every render.
|
||||
* @param chunk - the chunk name (see chunk-loader.ts).
|
||||
* @param pick - select the component from the chunk's exports.
|
||||
*/
|
||||
export function lazyChunkComponent<P extends object>(
|
||||
chunk: ChunkName,
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
|
||||
): (props: P) => ReactNode {
|
||||
return (props: P) => createElement(
|
||||
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
|
||||
{ chunk, pick, props },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Chat/GUI external-link interception: clicking an http(s) link that points
|
||||
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
|
||||
* sidebar browser instead of a new browser tab. Gated by BOTH the
|
||||
* `browserInterceptLinks` pref and the browser tab's enable switch; a
|
||||
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
|
||||
* user can still force a real browser tab.
|
||||
*
|
||||
* Only the GUI's OWN document is watched — links inside the browser tab's
|
||||
* sandboxed iframe live in another document and never bubble here (and
|
||||
* their clicks must keep working inside the sidebar).
|
||||
*/
|
||||
|
||||
/** The pure decision: the URL to open in the sidebar, or null to let the
|
||||
* click fall through. Extracted so the policy is unit-testable without a
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
|
||||
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(anchorHref)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
// Same-origin links are GUI-internal navigation (settings pages, tool
|
||||
// docs) — never routed into the sidebar browser.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return null
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): intercept defensively.
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Whether a left-click may be taken over (unmodified left click only). */
|
||||
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
|
||||
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level click capture that funnels external links
|
||||
* into the sidebar browser. Returns the disposer (HMR-safe).
|
||||
*/
|
||||
export function registerLinkInterception(opts: {
|
||||
takeoverEnabled: () => boolean
|
||||
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
|
||||
openInSidebar: (url: string) => void
|
||||
/** The GUI's own origin (window.location.origin at registration). */
|
||||
selfOrigin: string
|
||||
}): () => void {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!isPlainLeftClick(event)) return
|
||||
if (event.defaultPrevented) return
|
||||
if (!opts.takeoverEnabled()) return
|
||||
const target = event.target
|
||||
if (target === null || typeof (target as Element).closest !== 'function') return
|
||||
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
|
||||
if (anchor === null) return
|
||||
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
|
||||
if (url === null) return
|
||||
event.preventDefault()
|
||||
opts.openInSidebar(url)
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => { document.removeEventListener('click', onClick, true) }
|
||||
}
|
||||
+146
-12
@@ -18,8 +18,8 @@ const zh = {
|
||||
close: '关闭',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
expandFullscreen: '展开为全屏',
|
||||
restoreFullscreen: '还原宽度',
|
||||
collapseBottomPanel: '折叠底部面板',
|
||||
expandBottomPanel: '展开底部面板',
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
@@ -87,6 +87,7 @@ const zh = {
|
||||
disconnected: '终端连接断开,重连中…',
|
||||
exited: '终端进程已退出',
|
||||
noSession: '选择一个会话以使用侧边栏',
|
||||
pluginNotLoaded: '插件未加载,标签页暂不可用:',
|
||||
hiddenFiles: '隐藏文件',
|
||||
parent: '上级目录',
|
||||
copied: '已复制',
|
||||
@@ -104,6 +105,8 @@ const zh = {
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -116,12 +119,56 @@ const zh = {
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsTasksTitle: '有新后台任务时自动展开任务管理页',
|
||||
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
|
||||
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
settingsPopup: '功能设置',
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
viewerHtml: 'HTML',
|
||||
browser: '浏览器',
|
||||
browserPlaceholder: '输入网址,例如 example.com',
|
||||
browserGo: '前往',
|
||||
browserBack: '后退',
|
||||
browserForward: '前进',
|
||||
browserStart: '输入网址开始浏览(沙箱模式)',
|
||||
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
|
||||
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
|
||||
browserInvalid: '无效的网址',
|
||||
browserNoSandboxWarning: '沙箱已关闭:当前页面与界面同源,拥有完整会话权限(可在设置中恢复)',
|
||||
htmlNoSandboxWarning: '沙箱已关闭:此 HTML 与界面同源,可读取会话文件与内部接口(可在设置中恢复)',
|
||||
sandboxStatusOn: '沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用',
|
||||
sandboxUnlock: '临时解锁(不安全)',
|
||||
sandboxRestore: '恢复沙箱',
|
||||
settingsHtmlDefaultUnsafeTitle: 'HTML 预览默认以非沙箱模式打开(不安全)',
|
||||
settingsHtmlDefaultUnsafeDesc: '开启后,每次打开 HTML 文件时预览默认处于非沙箱状态(与界面同源,可读取会话文件与内部接口);可在状态行临时恢复沙箱',
|
||||
settingsHtmlSandboxTitle: '关闭 HTML 预览沙箱(不安全)',
|
||||
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
|
||||
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
|
||||
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
|
||||
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
|
||||
browserOpenExternal: '在浏览器中打开',
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
browserEmbedAnyway: '仍然加载',
|
||||
subagent: '任务管理',
|
||||
openSubagent: '任务管理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
@@ -135,6 +182,26 @@ const zh = {
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
tasks: '后台命令',
|
||||
tasksCount: '{count} 个后台命令',
|
||||
tasksCountRunning: '{count} 个后台命令 · {running} 运行中',
|
||||
taskStatusRunning: '运行中',
|
||||
taskStatusStopping: '终止中',
|
||||
taskStatusCompleted: '已完成',
|
||||
taskStatusKilled: '已终止',
|
||||
taskStatusFailed: '失败',
|
||||
taskDurationSeconds: '{seconds} 秒',
|
||||
taskDurationMinutes: '{minutes} 分 {seconds} 秒',
|
||||
taskDurationHours: '{hours} 小时 {minutes} 分',
|
||||
taskViewOutput: '查看输出',
|
||||
taskHideOutput: '收起输出',
|
||||
taskNoOutput: '暂无输出',
|
||||
taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)',
|
||||
taskOutputTruncated: '输出过长,已截断显示',
|
||||
taskOutputError: '输出读取失败',
|
||||
taskKill: '终止',
|
||||
taskKillConfirm: '再次点击确认终止',
|
||||
taskKillError: '终止失败',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
@@ -150,8 +217,8 @@ const en: Record<keyof typeof zh, string> = {
|
||||
close: 'Close',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
expandFullscreen: 'Expand to full width',
|
||||
restoreFullscreen: 'Restore width',
|
||||
collapseBottomPanel: 'Collapse bottom panel',
|
||||
expandBottomPanel: 'Expand bottom panel',
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
terminalRetry: 'Retry',
|
||||
@@ -219,6 +286,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',
|
||||
@@ -236,6 +304,8 @@ const en: Record<keyof typeof zh, string> = {
|
||||
settingsWidthTitle: 'Default width share',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
@@ -248,12 +318,56 @@ const en: Record<keyof typeof zh, string> = {
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsTasksTitle: 'Auto-open the Tasks page on a new background task',
|
||||
settingsTasksDesc: 'Expand the side card and open the Tasks page whenever a new background task appears for the current conversation (every new task triggers); turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
|
||||
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
settingsPopup: 'Feature settings',
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'Browser',
|
||||
browserPlaceholder: 'Enter a URL, e.g. example.com',
|
||||
browserGo: 'Go',
|
||||
browserBack: 'Back',
|
||||
browserForward: 'Forward',
|
||||
browserStart: 'Enter a URL to start browsing (sandbox mode)',
|
||||
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
|
||||
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
|
||||
browserInvalid: 'Invalid URL',
|
||||
browserNoSandboxWarning: 'Sandbox off: the current page runs with full GUI privileges (re-enable in settings)',
|
||||
htmlNoSandboxWarning: 'Sandbox off: this HTML runs with full GUI privileges (re-enable in settings)',
|
||||
sandboxStatusOn: 'Sandbox mode: on · pages cannot access the GUI\'s data or local files; logins and third-party cookies may not work',
|
||||
sandboxUnlock: 'Temporarily disable (unsafe)',
|
||||
sandboxRestore: 'Restore sandbox',
|
||||
settingsHtmlDefaultUnsafeTitle: 'Open HTML previews unsandboxed by default (unsafe)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'When on, every newly opened HTML preview starts in the unsandboxed state (same origin as the GUI — it can read session files and internal APIs); the status row still offers a one-tap restore',
|
||||
settingsHtmlSandboxTitle: 'Disable HTML preview sandbox (unsafe)',
|
||||
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
|
||||
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
|
||||
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
|
||||
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
|
||||
browserOpenExternal: 'Open in browser',
|
||||
browserEmbedBlocked: '{host} refused to be embedded',
|
||||
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
|
||||
browserEmbedAnyway: 'Load anyway',
|
||||
subagent: 'Tasks',
|
||||
openSubagent: 'Tasks',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
@@ -267,6 +381,26 @@ const en: Record<keyof typeof zh, string> = {
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
tasks: 'Background tasks',
|
||||
tasksCount: '{count} background tasks',
|
||||
tasksCountRunning: '{count} background tasks · {running} running',
|
||||
taskStatusRunning: 'Running',
|
||||
taskStatusStopping: 'Stopping',
|
||||
taskStatusCompleted: 'Completed',
|
||||
taskStatusKilled: 'Killed',
|
||||
taskStatusFailed: 'Failed',
|
||||
taskDurationSeconds: '{seconds}s',
|
||||
taskDurationMinutes: '{minutes}m {seconds}s',
|
||||
taskDurationHours: '{hours}h {minutes}m',
|
||||
taskViewOutput: 'View output',
|
||||
taskHideOutput: 'Hide output',
|
||||
taskNoOutput: 'No output yet',
|
||||
taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output',
|
||||
taskOutputTruncated: 'Output truncated',
|
||||
taskOutputError: 'Failed to read output',
|
||||
taskKill: 'Kill',
|
||||
taskKillConfirm: 'Click again to confirm kill',
|
||||
taskKillError: 'Kill failed',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared pieces of the Office preview views (docx / xlsx): the load-state
|
||||
* union, the shared props, and the download fallback. The two views live in
|
||||
* their own modules (docx-view.tsx / xlsx-view.tsx) so the bundler can emit
|
||||
* them as separate lazy chunks — docx-preview and the Univer family (several
|
||||
* MB) must never share a script (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
*/
|
||||
import { downloadUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
export interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
export function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from {@link EditorView} so the mapping is
|
||||
* preview kinds. Kept separate from the editor host so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
@@ -36,7 +36,7 @@ export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`,
|
||||
* @param ext - the path extension (with leading dot) (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
|
||||
+10
-247
@@ -1,249 +1,12 @@
|
||||
/**
|
||||
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||
* bytes through the existing /sidebar/file media route and own their library
|
||||
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||
* TerminalView dispose discipline).
|
||||
*
|
||||
* Errors degrade to the same download-button affordance the binary
|
||||
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||
* leaves the user with a way to get the file.
|
||||
*
|
||||
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||
* Compat re-export shim for the historical `src/client/office-view.tsx`
|
||||
* path. The office preview views were split into docx-view.tsx /
|
||||
* xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
|
||||
* as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* This module keeps deep imports of this path resolvable through the
|
||||
* `./src/*` exports subpath — it deliberately re-exports BOTH views, so it
|
||||
* must never be imported from the core bundle or a chunk entry (it would
|
||||
* drag both libraries into that bundle).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||
// contains all of them).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder's download link in EditorView.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { DocxView } from './docx-view.tsx'
|
||||
export { XlsxView } from './xlsx-view.tsx'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Interception of the chat's file-open funnel. The client runtime's
|
||||
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
|
||||
* through — ui-conversation's apply.ts resolves the path against the session
|
||||
* cwd and calls it for tool-row path links, the produced-files row, and
|
||||
* prose file mentions alike (verified against the DSH source:
|
||||
* `packages/client/ui-conversation/src/client/apply.ts` is the only
|
||||
* production caller). Wrapping that one method reroutes those opens into the
|
||||
* sidebar editor instead of the Host OS — no DSH modification needed.
|
||||
*
|
||||
* The wrapper is dependency-free by design (no React / ui-primitives), so
|
||||
* the takeover logic is unit-testable and the file stays importable from the
|
||||
* test runtime.
|
||||
*/
|
||||
|
||||
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
|
||||
export interface OpenPathService {
|
||||
openPath(path: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
|
||||
export interface OpenPathInterceptDeps {
|
||||
/**
|
||||
* Whether to take over this call: the `interceptOpenPath` pref AND the
|
||||
* editor tab's own enable switch must both be on (an editor that cannot
|
||||
* open must not swallow opens — they fall through to the Host).
|
||||
*/
|
||||
takeoverEnabled(): boolean
|
||||
/** The session whose scope the sidebar editor loads the file in (current session). */
|
||||
currentSessionId(): string | undefined
|
||||
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
||||
openInSidebar(path: string, sessionId: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
|
||||
* editor instead of the Host OS and resolve as success (the original's
|
||||
* callers ignore the result); anything that declines falls through to the
|
||||
* original method untouched.
|
||||
* @param workspaces - the client workspaces service to wrap.
|
||||
* @param deps - per-call takeover decisions.
|
||||
* @returns the disposer restoring the original method (HMR-safe).
|
||||
*/
|
||||
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
|
||||
// The RAW method reference (never a bound copy): restore must put back the
|
||||
// exact original so a chain of wrappers (other plugins wrapping the same
|
||||
// method) keeps working across disposals in any order.
|
||||
const original = workspaces.openPath
|
||||
workspaces.openPath = (path: string): Promise<void> => {
|
||||
if (deps.takeoverEnabled()) {
|
||||
const sessionId = deps.currentSessionId()
|
||||
if (sessionId !== undefined) {
|
||||
deps.openInSidebar(path, sessionId)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return original.call(workspaces, path)
|
||||
}
|
||||
return () => {
|
||||
workspaces.openPath = original
|
||||
}
|
||||
}
|
||||
+38
-2
@@ -21,8 +21,7 @@ export type { SidebarPrefs }
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/**
|
||||
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
@@ -41,12 +40,49 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
autoOpenTasks: typeof record.autoOpenTasks === 'boolean'
|
||||
? record.autoOpenTasks
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
||||
? record.bottomPanelAutoTerminal
|
||||
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
|
||||
? record.htmlViewerDefaultUnsafe
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
|
||||
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
|
||||
? record.browserNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
|
||||
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
|
||||
? record.browserInterceptLinks
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
|
||||
* survive; a non-object or a non-boolean entry falls back to the empty map /
|
||||
* drops the entry — an absent key means the feature stays enabled.
|
||||
*/
|
||||
function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, boolean> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof item === 'boolean') out[key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The BetterSidebar client service: a registry that external plugins use
|
||||
* to contribute sidebar tab types and file previewers. The service is
|
||||
* published to the cordis context as `ctx.betterSidebar` (see
|
||||
* {@link ../context-types.ts}); consumers declare it in `inject` and call
|
||||
* `registerTab` / `registerFileViewer`, both returning a disposer that
|
||||
* cordis auto-invokes on fiber disposal (HMR-safe).
|
||||
*
|
||||
* Design notes:
|
||||
* - The registry is synchronous-snapshot (Map + listener set) so React
|
||||
* can read it through `useSyncExternalStore` without tearing.
|
||||
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
|
||||
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
|
||||
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
|
||||
* `single: true` is sugar for `dedupeKey: () => id`.
|
||||
* - `createTab` lets a descriptor own tab instantiation (the terminal
|
||||
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
|
||||
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
|
||||
* per descriptor it tries `detect` first (when `head` bytes are given),
|
||||
* then `exts`; `exts: []` is a catch-all that matches any path.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
/** Row title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** The explorer's expanded directory set (ExplorerView). */
|
||||
expanded?: string[]
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Describes one kind of sidebar tab (builtins register themselves too). */
|
||||
export interface TabDescriptor {
|
||||
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + menu sort order (ascending); default 100. */
|
||||
order?: number
|
||||
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
|
||||
hidden?: boolean
|
||||
/**
|
||||
* + menu disabled predicate (e.g. terminal at capacity). Receives the
|
||||
* session scope and the live sidebar state (counts, expansions).
|
||||
*/
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
|
||||
* (opening the tab focuses an existing one of the same type instead of
|
||||
* creating a duplicate). An explicit `dedupeKey` always wins when both
|
||||
* are given. Builtins: explorer/git/subagent use `single: true`.
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
|
||||
* tab's key focuses the existing one instead of creating a new one.
|
||||
* Returning `undefined` means "no dedup — always open a new tab".
|
||||
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
|
||||
* (openDiffTab mints change-derived ids).
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* Custom tab creation (minting the `SidebarTab` and any state patches).
|
||||
* Return `null` to refuse creation. The terminal builtin uses this to
|
||||
* mint `terminal:<n>` ids and bump `nextTerminal`.
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
/** How the host loads a file's bytes for one viewer. */
|
||||
export type FileFetchStrategy =
|
||||
| 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
|
||||
| 'fsRead' // text read through /sidebar/api fs.read
|
||||
| 'mediaUrl' // the viewer gets a media URL string
|
||||
| 'custom' // the viewer's load() fetches its own bytes
|
||||
| 'binary-download' // show a download button (no client-side renderer)
|
||||
|
||||
/** Props every file viewer component receives. */
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
|
||||
viewerId: string
|
||||
/** fsRead text content (fetchStrategy='fsRead'). */
|
||||
content?: string
|
||||
truncated?: boolean
|
||||
/** mediaUrl for the path (fetchStrategy='mediaUrl'). */
|
||||
mediaUrl?: string
|
||||
/** custom load() return value (fetchStrategy='custom'). */
|
||||
customData?: unknown
|
||||
}
|
||||
|
||||
/** Describes one file previewer (builtins register themselves too). */
|
||||
export interface FileViewerDescriptor {
|
||||
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
|
||||
id: string
|
||||
/** Display name for the settings inventory (falls back to `id` when absent). */
|
||||
title?: string | (() => string)
|
||||
/** Icon shown in the settings inventory. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
|
||||
exts: readonly string[]
|
||||
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
|
||||
priority?: number
|
||||
fetchStrategy: FileFetchStrategy
|
||||
/**
|
||||
* Content sniff: when `head` bytes are available the descriptor's `detect`
|
||||
* is consulted before its `exts` (per-descriptor, in priority order).
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/**
|
||||
* Whether a tab type is enabled in the side card prefs. An absent
|
||||
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
|
||||
* disables the type (hidden from the + menu, `openTab` refuses, and
|
||||
* derived flows gate on it).
|
||||
*/
|
||||
isTabEnabled(id: string): boolean
|
||||
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/**
|
||||
* Find a file viewer for a path (priority desc; detect first, then exts).
|
||||
* Disabled viewers are skipped, so files fall through to the next match.
|
||||
*/
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
|
||||
* pre-set to the URL (the browser tab's navigation seed; the caller
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
* to the store's reducer. One instance per client plugin activation.
|
||||
*/
|
||||
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
|
||||
const tabs = new Map<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const notify = (): void => {
|
||||
for (const fn of [...listeners]) fn()
|
||||
}
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
const registerTab = (descriptor: TabDescriptor): (() => void) => {
|
||||
if (tabs.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
|
||||
}
|
||||
tabs.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (tabs.get(descriptor.id) === descriptor) {
|
||||
tabs.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
|
||||
if (viewers.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
|
||||
}
|
||||
viewers.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (viewers.get(descriptor.id) === descriptor) {
|
||||
viewers.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
|
||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||
|
||||
// The enable switches come from the user's side card prefs (the shared
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Single pass in priority order (descending; stable for equal
|
||||
// priorities — insertion order). Each descriptor gets first refusal in
|
||||
// its own turn: `detect` (when head bytes are available) beats its own
|
||||
// `exts`, and `exts: []` is a catch-all matching any path — so the
|
||||
// catch-all `code` viewer (-100) only sees paths no higher-priority
|
||||
// descriptor claimed. Disabled viewers are skipped entirely.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (!isViewerEnabled(v.id)) continue
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
|
||||
// paths it never sniffed (a magic-number viewer must not swallow
|
||||
// every file before the real viewers get their turn).
|
||||
if (v.exts.length === 0) continue
|
||||
} else if (v.exts.length === 0) {
|
||||
// Blind catch-all (no detect) claims anything; a sniff-only
|
||||
// catch-all (detect defined, no head yet) yields this round.
|
||||
if (v.detect === undefined) return v
|
||||
continue
|
||||
}
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
if (!isTabEnabled(seed.type)) {
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
if (descriptor.createTab !== undefined) {
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
tab = result.tab
|
||||
next = applyDedupe(state, result.tab, descriptor)
|
||||
if (result.patch !== undefined) next = { ...next, ...result.patch }
|
||||
} else {
|
||||
tab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
// A caller-provided title wins (the editor shows the file name);
|
||||
// otherwise the descriptor's (possibly i18n) title is the default.
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
}
|
||||
// A URL seed pre-fills the tab's path (the browser tab navigates to it
|
||||
// on mount). An explicit seed.title also wins over a createTab-minted
|
||||
// default title (e.g. the sidebar-browser's hostname title).
|
||||
let landed: SidebarState
|
||||
if (seed.url !== undefined) {
|
||||
landed = patchTab(next, tab.id, {
|
||||
path: seed.url,
|
||||
...(seed.title !== undefined ? { title: seed.title } : {}),
|
||||
})
|
||||
} else {
|
||||
landed = next
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
// (panelOpen) is the only lever; on wide viewports the landing pane's
|
||||
// own panel opens — the bottom panel when the active pane lives in the
|
||||
// bottom tree, else the right panel. Type-only opens (+ menu,
|
||||
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||
// caller's business). The check runs on the post-dedupe state, so a
|
||||
// content open that merely FOCUSES an existing tab expands the panel
|
||||
// too — the open must never land out of sight.
|
||||
if (
|
||||
typeof window !== 'undefined'
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
) {
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
if (!landed.panelOpen) return togglePanel(landed)
|
||||
} else {
|
||||
const hostKey = treeOf(landed, landed.activePane ?? '')
|
||||
if (hostKey === 'bottomSplits') {
|
||||
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
|
||||
} else if (!landed.panelOpen) {
|
||||
return togglePanel(landed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return landed
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
store.reduce((state) => {
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registerTab,
|
||||
registerFileViewer,
|
||||
getTabs,
|
||||
getFileViewers,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
|
||||
* same type exists, focus it; otherwise land the tab through
|
||||
* `openTabInActivePane` (the id safety net + active-pane landing are that
|
||||
* reducer's job — not re-implemented here).
|
||||
* `single: true` resolves to the id-key sugar when no explicit key is given.
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
// The scan covers BOTH trees: opening a single-instance tab from the
|
||||
// bottom panel focuses an existing instance wherever it lives.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
|
||||
function findPaneIdOf(state: SidebarState, tabId: string): string {
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
|
||||
}
|
||||
return state.activePane ?? ''
|
||||
}
|
||||
+364
-38
@@ -10,27 +10,39 @@
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.toggleRail {
|
||||
/* The persistent expand/collapse cluster at the top-right corner: two rail
|
||||
controls side by side (bottom panel's glyph LEFT of the right panel's).
|
||||
Always pinned to the viewport corner — inside the right panel's top-right
|
||||
while it is open, sitting flush in the 34px tab strip (28px buttons at
|
||||
top: 3) whose right end it really squeezes. */
|
||||
.toggleCluster {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
top: 3px;
|
||||
right: 10px;
|
||||
z-index: 55;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Native rail inset: the 36px control sits 10px off the column edge. */
|
||||
padding: 0 10px;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* The closed-state affordance is the native rail icon control: a plain 36px
|
||||
circle, transparent fill, secondary ink; hover raises the fill. No border,
|
||||
no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */
|
||||
/* The tab strip of the OPEN right panel reserves the cluster's width at its
|
||||
right end, so the tabs and the + menu genuinely yield space to the cluster
|
||||
(never hiding under it). */
|
||||
.panel:not(.panelHidden) .tabBar {
|
||||
padding-right: 72px;
|
||||
}
|
||||
|
||||
/* The closed-state affordance is the native rail icon control: a plain
|
||||
28px circle (the app's icon-button size — the cluster must stay inside
|
||||
the 34px tab-strip band so it never covers the pane headers below),
|
||||
transparent fill, secondary ink; hover raises the fill. No border, no
|
||||
shadow — the icon alone is the affordance. */
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
@@ -55,6 +67,8 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
/* Full height: the bottom panel squeezes only the center column, so the
|
||||
right panel never gives up any vertical position. */
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
@@ -100,25 +114,6 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -126,6 +121,112 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Bottom panel ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* The bottom panel: an independent workbench at the bottom of the CENTER
|
||||
column only (the Sidebar shell writes left/right inline: from the app's
|
||||
own left sidebar to the right panel's left edge) — the sidebars never
|
||||
give up any position. Same surface as the right panel, with a top
|
||||
hairline instead of the left one. */
|
||||
.bottomPanel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
/* Expand/collapse slides the panel; height drags animate the height. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
|
||||
.bottomPanelHidden {
|
||||
transform: translateY(102%);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
visibility 0s linear var(--ds-transition-duration-slow);
|
||||
}
|
||||
|
||||
/* Height drags write at pointer cadence; easing would detach the edge. */
|
||||
.bottomPanel[data-dragging] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The bottom panel's height drag: the top edge strip (mirror of the right
|
||||
panel's width strip). */
|
||||
.bottomResize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.bottomResizeActive {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
/* The bottom panel's close control: the app's icon-button pattern (28px
|
||||
circle), pinned to the tab strip's right end — one tap collapses the
|
||||
panel. The tab strips below reserve its width so the + menu never hides
|
||||
under it. */
|
||||
.bottomClose {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 6px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bottomClose:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The bottom panel's tab strips reserve the close control's width at their
|
||||
right end, so the + menu never hides under it. */
|
||||
.bottomPanel .tabBar {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
resize the width, vertical drags the height — the two panels drag
|
||||
against each other. */
|
||||
.cornerHandle {
|
||||
position: fixed;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
z-index: 52;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.cornerHandle:hover,
|
||||
.cornerHandle[data-dragging] {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
/* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px
|
||||
circle, secondary label, hover fill; no border so the icon alone is the
|
||||
affordance. */
|
||||
@@ -317,28 +418,35 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* An empty pane's welcome cards: a responsive grid of compact cards — as
|
||||
many per row as the pane width allows; rows that cannot fit the pane
|
||||
height are clipped (never scrolled or squeezed smaller). */
|
||||
.paneEmptyCards {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paneCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.paneCard:hover:not(:disabled) {
|
||||
@@ -640,6 +748,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The unregistered tab type shown by the orphaned-tab placeholder. */
|
||||
.orphanedType {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
|
||||
a centered notice plus a download link — the user always gets the file. */
|
||||
.editorBinary {
|
||||
@@ -694,6 +811,186 @@
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
/* Live sandbox status row (HTML preview / browser tab): GREEN while the
|
||||
sandbox is on (with the one-tap temporary unlock), RED while it is off
|
||||
(global setting or temporary unlock) — the red state is the persistent
|
||||
warning that the page runs on the GUI's own origin. */
|
||||
.sandboxStatus {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
.sandboxStatusOn {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.sandboxStatusOff {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
||||
}
|
||||
|
||||
.sandboxDot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.sandboxStatusOff .sandboxDot {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.sandboxStatusText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sandboxAction {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sandboxAction:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
|
||||
so the frame is cross-origin by construction even if the sandbox
|
||||
attribute is ever dropped; sandboxed by default (see TextEditor). */
|
||||
.editorHtml {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* ── Browser tab ──────────────────────────────────────────────────────── */
|
||||
|
||||
.browser {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.browserBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.browserInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.browserInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.browserMessage {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.browserFrame {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.browserStart {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Embed-refusal panel (X-Frame-Options / frame-ancestors): replaces the
|
||||
blank refused iframe with the reason + open-in-browser actions. */
|
||||
.browserBlocked {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.browserBlockedTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.browserBlockedDesc {
|
||||
max-width: 280px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.browserBlockedActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.browserBlockedButton {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browserBlockedButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.editorCm {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1524,6 +1821,7 @@
|
||||
/* Keyboard focus stays visible on every interactive control (the styling
|
||||
policy requires focus visibility; the accent token doubles as the ring). */
|
||||
.toggleButton:focus-visible,
|
||||
.bottomClose:focus-visible,
|
||||
.iconButton:focus-visible,
|
||||
.tab:focus-visible,
|
||||
.tabClose:focus-visible,
|
||||
@@ -1550,6 +1848,9 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.panelHidden,
|
||||
.bottomPanel,
|
||||
.bottomPanelHidden,
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tab,
|
||||
.tabBarPlus,
|
||||
@@ -1563,3 +1864,28 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
|
||||
|
||||
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
|
||||
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
|
||||
mobile layout — one full-screen drawer (the bottom panel's tabs were
|
||||
migrated into the right tree), one toggle button. The structural
|
||||
switches (which panels render, the migration) are JS-driven via
|
||||
useNarrowViewport; this block only adapts the pure style details. */
|
||||
@media (max-width: 767px) {
|
||||
/* The toggle cluster holds a single button on narrow viewports (the
|
||||
bottom panel button is not rendered), so the open panel's tab strip
|
||||
reserves 40px instead of the desktop 72px — the tabs and + menu still
|
||||
yield space to the one remaining control. */
|
||||
.panel:not(.panelHidden) .tabBar {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Narrower tabs: a phone-width drawer fits more open tabs before the
|
||||
strip scrolls. */
|
||||
.tab {
|
||||
min-width: 48px;
|
||||
max-width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -258,24 +262,30 @@ function NodeView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** The workbench: the split tree filling the sidebar body. */
|
||||
/** The workbench: the split tree filling the sidebar body. `tree` selects
|
||||
* which tree renders (the right panel's by default; the bottom panel passes
|
||||
* `state.bottomSplits` — the actions route by pane id, so one action set
|
||||
* serves both). */
|
||||
export function Workbench(props: {
|
||||
state: SidebarState
|
||||
tree?: SplitNode
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
node={state.splits}
|
||||
node={tree ?? state.splits}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+337
-69
@@ -10,8 +10,15 @@
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff'
|
||||
/**
|
||||
* Tab type identifier. Builtins register their ids (explorer / git / editor
|
||||
* / terminal / subagent / diff) through the sidebar service; external
|
||||
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
|
||||
* the registry stays open.
|
||||
*/
|
||||
export type TabType = string
|
||||
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
@@ -51,20 +58,41 @@ export type SplitNode = SidebarLeaf | SidebarSplit
|
||||
export interface SidebarState {
|
||||
panelOpen: boolean
|
||||
width: number
|
||||
/** The pane receiving newly opened tabs (last pane the user touched). */
|
||||
/** The pane receiving newly opened tabs (last pane the user touched).
|
||||
* Pane ids are globally unique across BOTH trees (shared uid counter), so
|
||||
* one field resolves into either tree — see {@link treeOf}. */
|
||||
activePane: string | null
|
||||
/** Monotonic terminal tab counter (ids survive reloads). */
|
||||
nextTerminal: number
|
||||
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
|
||||
nextBrowser: number
|
||||
/** Explorer expansion set (absolute directory paths). */
|
||||
expanded: string[]
|
||||
/** The right sidebar's split tree (the original workbench). */
|
||||
splits: SplitNode
|
||||
/** Whether the bottom panel (a second, independent workbench) is open. */
|
||||
bottomOpen: boolean
|
||||
/** The bottom panel's height (clamped to the contract range). */
|
||||
bottomHeight: number
|
||||
/**
|
||||
* Whether the bottom panel has been expanded at least once in this
|
||||
* session — the FIRST expansion tries to auto-open a terminal tab (gated
|
||||
* on the bottomPanelAutoTerminal pref); later expansions never do.
|
||||
*/
|
||||
bottomOpenedOnce: boolean
|
||||
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
|
||||
* tabs never cross panels — the two panels only share panel-size drags). */
|
||||
bottomSplits: SplitNode
|
||||
}
|
||||
|
||||
export const PANEL_MIN = 280
|
||||
export const PANEL_MAX = 640
|
||||
export const PANEL_DEFAULT = 400
|
||||
export const TAB_MAX_WIDTH = 160
|
||||
export const TERMINAL_LIMIT = 3
|
||||
/** Bottom panel geometry contract (mirrors the width contract; the upper
|
||||
* bound is the viewport, enforced by {@link setBottomHeight}). */
|
||||
export const BOTTOM_MIN = 120
|
||||
export const BOTTOM_DEFAULT = 220
|
||||
|
||||
let nextIdCounter = 0
|
||||
/** Unique pane/tab id within one state instance. */
|
||||
@@ -103,27 +131,57 @@ function maxCounterId(parsed: unknown): number {
|
||||
}
|
||||
}
|
||||
walk((parsed as Record<string, unknown> | null)?.splits)
|
||||
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
|
||||
return max
|
||||
}
|
||||
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
* true); the store seeds new sessions from the user's side card prefs.
|
||||
* `seedExplorer` places the default explorer tab — the store passes false
|
||||
* when the user disabled the explorer tab type in settings, so a fresh
|
||||
* session starts with an empty pane instead of a tab they turned off. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
if (seedExplorer) {
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
}
|
||||
// The bottom panel starts closed with an empty pane (its welcome cards
|
||||
// offer the openable types on first use).
|
||||
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
return {
|
||||
panelOpen,
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
nextTerminal: 1,
|
||||
nextBrowser: 1,
|
||||
expanded: [],
|
||||
splits: leaf,
|
||||
bottomOpen: false,
|
||||
bottomHeight: BOTTOM_DEFAULT,
|
||||
bottomOpenedOnce: false,
|
||||
bottomSplits: bottomLeaf,
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tree node (or any descendant) carries the given pane/split id. */
|
||||
function treeHasId(node: SplitNode, id: string): boolean {
|
||||
if (node.id === id) return true
|
||||
if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
|
||||
return false
|
||||
}
|
||||
|
||||
/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
|
||||
* bottom panel's tree, else 'splits' (the right panel's tree). Ids are
|
||||
* globally unique (the shared uid counter), so an id in neither tree falls
|
||||
* back to the right tree, where tree operations no-op on a missing node —
|
||||
* the pre-bottom-panel behavior. */
|
||||
export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
|
||||
return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
|
||||
}
|
||||
|
||||
/** Walk the tree and apply `visit` to the leaf with the given id. */
|
||||
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -148,6 +206,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
|
||||
return firstLeaf(node.children[0]!)
|
||||
}
|
||||
|
||||
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
|
||||
function clearAllTabs(node: SplitNode): SplitNode {
|
||||
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
|
||||
return { ...node, children: node.children.map(clearAllTabs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
|
||||
* right sidebar — the "merged display" on mobile is the right panel alone,
|
||||
* whose tab strips now carry the bottom tree's tabs (depth-first order,
|
||||
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
|
||||
* structure stays — the desktop bottom panel re-renders its welcome cards)
|
||||
* and the panel closes. The active pane moves to the right tree's first
|
||||
* leaf so every new tab lands in the visible panel.
|
||||
*
|
||||
* Idempotent: a bottom tree with no tabs and a closed panel returns the
|
||||
* same reference. Runs when the viewport enters narrow (see the Sidebar
|
||||
* shell); migrating is permanent for the session — the tabs now live in the
|
||||
* right tree, exactly like the user "threw them in".
|
||||
*/
|
||||
export function migrateBottomTabs(state: SidebarState): SidebarState {
|
||||
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
|
||||
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
|
||||
const target = firstLeaf(state.splits)
|
||||
return {
|
||||
...state,
|
||||
activePane: target.id,
|
||||
bottomOpen: false,
|
||||
splits: bottomTabs.length > 0
|
||||
? mapLeaf(state.splits, target.id, leaf => {
|
||||
leaf.tabs = [...leaf.tabs, ...bottomTabs]
|
||||
})
|
||||
: state.splits,
|
||||
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the leaf containing a tab id, if any. */
|
||||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -166,9 +262,10 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||||
return node.children.flatMap(allLeaves)
|
||||
}
|
||||
|
||||
/** Whether a tab exists anywhere in a state tree (any pane). */
|
||||
/** Whether a tab exists anywhere in a state (either tree, any pane). */
|
||||
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
|
||||
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
}
|
||||
|
||||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||||
@@ -224,6 +321,9 @@ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
|
||||
* The VSCode drag gesture: move a tab out of its pane and either merge it
|
||||
* into the target pane (center) or split the target pane with the tab in a
|
||||
* fresh leaf (edge). The source pane collapses when it empties.
|
||||
*
|
||||
* The panes may live in DIFFERENT trees (dragging a tab between the two
|
||||
* panels): the tab then leaves its own tree and lands in the other one.
|
||||
*/
|
||||
export function moveTabToEdge(
|
||||
state: SidebarState,
|
||||
@@ -236,11 +336,43 @@ export function moveTabToEdge(
|
||||
// Dropped back onto its own pane's center: reorder to the end.
|
||||
return moveTab(state, fromPane, tabId, toPane, -1)
|
||||
}
|
||||
const source = leafWithTab(state.splits, tabId)
|
||||
const key = treeOf(state, fromPane)
|
||||
const toKey = treeOf(state, toPane)
|
||||
if (key !== toKey) {
|
||||
// Cross-panel drop: remove the tab from its own tree, then merge (center)
|
||||
// or split (edge) a pane of the OTHER tree with the tab.
|
||||
const source = leafWithTab(state[key], tabId)
|
||||
if (source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
let emptied = false
|
||||
let sourceNode = mapLeaf(state[key], source.id, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
if (emptied) sourceNode = removeLeafAt(sourceNode, source.id)
|
||||
let targetNode = state[toKey]
|
||||
let activePane: string
|
||||
if (zone === 'center') {
|
||||
targetNode = mapLeaf(targetNode, toPane, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
})
|
||||
activePane = toPane
|
||||
} else {
|
||||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||||
const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||||
targetNode = result.node
|
||||
activePane = result.leafId
|
||||
}
|
||||
return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
|
||||
}
|
||||
const node = state[key]
|
||||
const source = leafWithTab(node, tabId)
|
||||
if (source === undefined) return state
|
||||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||||
let emptied = false
|
||||
let splits = mapLeaf(state.splits, source.id, (leaf) => {
|
||||
let splits = mapLeaf(node, source.id, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
@@ -251,11 +383,11 @@ export function moveTabToEdge(
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
})
|
||||
return { ...state, splits, activePane: toPane }
|
||||
return { ...state, [key]: splits, activePane: toPane }
|
||||
}
|
||||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||||
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
return { ...state, [key]: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,77 +410,129 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
|
||||
|
||||
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
|
||||
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
const key = treeOf(state, paneId)
|
||||
let emptied = false
|
||||
const splits = mapLeaf(state.splits, paneId, (leaf) => {
|
||||
const splits = mapLeaf(state[key], paneId, (leaf) => {
|
||||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
|
||||
return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
|
||||
}
|
||||
|
||||
/** Activate a tab in its pane. */
|
||||
/** Activate a tab in its pane (the pane's own tree). */
|
||||
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||||
const key = treeOf(state, paneId)
|
||||
return {
|
||||
...state,
|
||||
activePane: paneId,
|
||||
splits: mapLeaf(state.splits, paneId, (leaf) => {
|
||||
[key]: mapLeaf(state[key], paneId, (leaf) => {
|
||||
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
|
||||
function isSingle(type: TabType): boolean {
|
||||
return type === 'explorer' || type === 'git' || type === 'subagent'
|
||||
/** Update the display fields of one open tab (title / path) without
|
||||
* re-opening it. The browser tab persists its current URL and hostname
|
||||
* title through this reducer so a reload restores the visited page. A
|
||||
* missing tab id is a no-op. The tab may live in either tree. */
|
||||
export function patchTab(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
patch: { title?: string; path?: string },
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
if (node.kind === 'leaf') {
|
||||
const tabs = node.tabs.map(tab => {
|
||||
if (tab.id !== tabId) return tab
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
}
|
||||
})
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
}
|
||||
const children = node.children.map(walk)
|
||||
return children === node.children ? node : { ...node, children }
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
const bottomSplits = walk(state.bottomSplits)
|
||||
return changed ? { ...state, splits, bottomSplits } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
|
||||
* this reducer only handles the id-based safety net (reconcile and
|
||||
* openDiffTab already check existence before calling) and the landing
|
||||
* itself — the service's dedupe path delegates here after its dedupeKey
|
||||
* check misses.
|
||||
*
|
||||
* The active pane may live in EITHER tree (pane ids are globally unique):
|
||||
* a stale id that survives in neither tree falls back to the right tree's
|
||||
* first pane instead of swallowing the open.
|
||||
*/
|
||||
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
// A stale activePane (its pane was closed since) must not swallow the
|
||||
// open: fall back to the first pane instead of dropping the tab.
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
// open: fall back to the first pane of the right tree instead of dropping
|
||||
// the tab.
|
||||
if (!allLeaves(state[treeOf(state, targetId)]).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)
|
||||
}
|
||||
const targetKey = treeOf(state, targetId)
|
||||
// Id-based safety net: if a tab with the same id exists, focus it.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
splits: mapLeaf(state.splits, targetId, (leaf) => {
|
||||
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a tab from one pane to another (insert at index; -1 appends). */
|
||||
/** Move a tab from one pane to another (insert at index; -1 appends).
|
||||
* The panes may live in DIFFERENT trees — dragging a tab between the two
|
||||
* panels removes it from its own tree and lands it in the other one. */
|
||||
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
|
||||
const fromKey = treeOf(state, fromPane)
|
||||
const toKey = treeOf(state, toPane)
|
||||
if (fromKey !== toKey) {
|
||||
let moved: SidebarTab | undefined
|
||||
let emptied = false
|
||||
const source = mapLeaf(state[fromKey], fromPane, (leaf) => {
|
||||
const found = leaf.tabs.find(tab => tab.id === tabId)
|
||||
if (found === undefined) return
|
||||
moved = found
|
||||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||||
if (leaf.tabs.length === 0) emptied = true
|
||||
})
|
||||
if (moved === undefined) return state
|
||||
const target = mapLeaf(state[toKey], toPane, (leaf) => {
|
||||
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
|
||||
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
|
||||
leaf.active = moved!.id
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
[fromKey]: emptied ? removeLeafAt(source, fromPane) : source,
|
||||
[toKey]: target,
|
||||
activePane: toPane,
|
||||
}
|
||||
}
|
||||
let moved: SidebarTab | undefined
|
||||
let emptied = false
|
||||
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
|
||||
let splits = mapLeaf(state[fromKey], fromPane, (leaf) => {
|
||||
const found = leaf.tabs.find(tab => tab.id === tabId)
|
||||
if (found === undefined) return
|
||||
moved = found
|
||||
@@ -363,13 +547,14 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
|
||||
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
|
||||
leaf.active = moved!.id
|
||||
})
|
||||
return { ...state, splits, activePane: toPane }
|
||||
return { ...state, [fromKey]: splits, activePane: toPane }
|
||||
}
|
||||
|
||||
/** Split the active pane (or the pane containing the active tab). */
|
||||
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
|
||||
const paneId = state.activePane ?? firstLeaf(state.splits).id
|
||||
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
|
||||
const key = treeOf(state, paneId)
|
||||
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +563,12 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
|
||||
* already holds diff tabs (diff panes are sticky — repeated clicks stack
|
||||
* there); on the FIRST diff of a layout the source pane splits vertically so
|
||||
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
|
||||
*
|
||||
* This is split-tree placement surgery, not registry dispatch: the diff tab
|
||||
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
|
||||
* check below is exactly that rule — the two agree by construction (asserted
|
||||
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
|
||||
* the id check is the per-change dedupe.
|
||||
* @returns the new state, with the diff pane active.
|
||||
*/
|
||||
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
|
||||
@@ -398,7 +589,7 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side
|
||||
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
|
||||
// regular open path instead of dropping the tab into an orphaned leaf.)
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
|
||||
return openTab(state, tab)
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
@@ -409,6 +600,11 @@ export function togglePanel(state: SidebarState): SidebarState {
|
||||
return { ...state, panelOpen: !state.panelOpen }
|
||||
}
|
||||
|
||||
/** Toggle the bottom panel open/closed (independent of the right panel). */
|
||||
export function toggleBottomPanel(state: SidebarState): SidebarState {
|
||||
return { ...state, bottomOpen: !state.bottomOpen }
|
||||
}
|
||||
|
||||
/** Set the panel width (clamped to the contract range; the upper bound is
|
||||
* the viewport so the fullscreen expansion can fill the window). */
|
||||
export function setWidth(state: SidebarState, width: number): SidebarState {
|
||||
@@ -416,6 +612,16 @@ export function setWidth(state: SidebarState, width: number): SidebarState {
|
||||
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
|
||||
}
|
||||
|
||||
/** Set the bottom panel height (clamped to the contract range). The upper
|
||||
* bound leaves the center column (the agent output area) at least PANEL_MIN
|
||||
* tall — without the cap the bottom panel could swallow the whole viewport
|
||||
* and squeeze the conversation to zero height. */
|
||||
export function setBottomHeight(state: SidebarState, height: number): SidebarState {
|
||||
const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||||
const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
|
||||
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
|
||||
}
|
||||
|
||||
/** Toggle a directory in the explorer expansion set. */
|
||||
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
|
||||
const expanded = state.expanded.includes(path)
|
||||
@@ -442,6 +648,13 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
|
||||
}
|
||||
}
|
||||
|
||||
/** State-level {@link resizeSplit} route: the divider may live in either
|
||||
* tree (split ids are globally unique). */
|
||||
export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
|
||||
const key = treeOf(state, splitId)
|
||||
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
|
||||
}
|
||||
|
||||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||||
export const AGENT_TAB_PREFIX = 'agent:'
|
||||
|
||||
@@ -476,7 +689,7 @@ export function reconcileAgentTerminals(
|
||||
state: SidebarState,
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
||||
): SidebarState {
|
||||
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
|
||||
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
|
||||
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
|
||||
@@ -493,7 +706,7 @@ export function reconcileAgentTerminals(
|
||||
}
|
||||
}
|
||||
// Add tabs for new uuids (the agent created a terminal). They land in the
|
||||
// active pane via openTab; the next reconcile is a no-op for them.
|
||||
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
|
||||
let next: SidebarState = { ...state, splits }
|
||||
for (const terminal of toAdd) {
|
||||
const tab: SidebarTab = {
|
||||
@@ -501,7 +714,7 @@ export function reconcileAgentTerminals(
|
||||
type: 'terminal',
|
||||
title: terminal.title,
|
||||
}
|
||||
next = openTab(next, tab)
|
||||
next = openTabInActivePane(next, tab)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -514,6 +727,12 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||||
export interface SidebarSnapshot {
|
||||
sessionId: string | undefined
|
||||
state: SidebarState | undefined
|
||||
/**
|
||||
* The current side card prefs. Carried IN the snapshot (not a separate
|
||||
* subscription) so prefs changes re-render the consumers that gate on
|
||||
* them — the + menu hides a tab type the moment its switch flips.
|
||||
*/
|
||||
prefs: SidebarPrefs
|
||||
}
|
||||
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
@@ -539,13 +758,20 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), and the
|
||||
// panel starts open only when the preference says so.
|
||||
// viewport so a huge percent can never crush the app shell), the panel
|
||||
// starts open only when the preference says so, and the default explorer
|
||||
// tab is skipped when the user disabled the explorer tab type. On a
|
||||
// NARROW viewport a brand-new session starts collapsed instead — the
|
||||
// panel is a full-screen drawer there, and auto-opening it on first
|
||||
// paint would cover the conversation before the user asked. Only the
|
||||
// first seeding is affected: once the user expands the drawer,
|
||||
// `panelOpen: true` persists like any other state.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
|
||||
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,11 +790,37 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
|
||||
return undefined
|
||||
}
|
||||
// nextBrowser arrived in a later build; a missing or malformed value on an
|
||||
// OLDER persisted state defaults to 1 so existing layouts keep loading
|
||||
// (unlike nextTerminal, which is strict — it predates the v1 shape).
|
||||
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
|
||||
? record.nextBrowser
|
||||
: 1
|
||||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||||
// The seen/reid maps are SHARED across both trees: pane/split ids must be
|
||||
// globally unique (the runtime uid counter is shared too), so a duplicate
|
||||
// seen first in the right tree gets a fresh id when it reappears in the
|
||||
// bottom tree.
|
||||
const seen = new Set<string>()
|
||||
const reid = new Map<string, string>()
|
||||
const splits = sanitizeNode(record.splits, new Set(), reid)
|
||||
const splits = sanitizeNode(record.splits, seen, reid)
|
||||
if (splits === undefined) return undefined
|
||||
// Bottom-panel fields arrived in a later build: a missing or malformed
|
||||
// value on an OLDER persisted state defaults (closed / default height /
|
||||
// empty pane) so existing layouts keep loading, like nextBrowser.
|
||||
const bottomOpen = record.bottomOpen === true
|
||||
// Cap the persisted height so the center column (the agent output area)
|
||||
// keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
|
||||
// older build must never squeeze the conversation to zero).
|
||||
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||||
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
|
||||
const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
|
||||
? record.bottomHeight
|
||||
: BOTTOM_DEFAULT
|
||||
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
|
||||
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
|
||||
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
|
||||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||||
return {
|
||||
panelOpen: record.panelOpen,
|
||||
@@ -577,8 +829,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
// new tabs still land in the pane the user was using.
|
||||
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
|
||||
nextTerminal: record.nextTerminal,
|
||||
nextBrowser,
|
||||
expanded: record.expanded as string[],
|
||||
splits,
|
||||
bottomOpen,
|
||||
bottomHeight,
|
||||
// An older persisted state never expanded the bottom panel (the field
|
||||
// arrived later): defaulting to false gives it the first-expansion
|
||||
// auto-terminal exactly once after the upgrade.
|
||||
bottomOpenedOnce: record.bottomOpenedOnce === true,
|
||||
bottomSplits,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,13 +882,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,
|
||||
@@ -666,15 +923,26 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
/** The session-scoped store: one state per conversation, localStorage-backed. */
|
||||
export class SidebarStore {
|
||||
private readonly bySession = new Map<string, SidebarState>()
|
||||
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
|
||||
private snapshot: SidebarSnapshot = {
|
||||
sessionId: undefined,
|
||||
state: undefined,
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
/**
|
||||
* Replace the side card prefs (the settings RPC result / settings page
|
||||
* write). Notifies like any store change: the snapshot carries the prefs,
|
||||
* so consumers that gate on enable switches (the + menu, derived flows)
|
||||
* re-render with the new values immediately.
|
||||
*/
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
this.snapshot = { ...this.snapshot, prefs: this.prefs }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
@@ -686,7 +954,7 @@ export class SidebarStore {
|
||||
setSession(sessionId: string | undefined): void {
|
||||
if (this.snapshot.sessionId === sessionId) return
|
||||
if (sessionId === undefined) {
|
||||
this.snapshot = { sessionId: undefined, state: undefined }
|
||||
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
@@ -698,7 +966,7 @@ export class SidebarStore {
|
||||
// pane/split ids can never collide with its tree.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
this.snapshot = { sessionId, state }
|
||||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
@@ -720,7 +988,7 @@ export class SidebarStore {
|
||||
const draft = structuredClone(state)
|
||||
mutator(draft)
|
||||
this.bySession.set(sessionId, draft)
|
||||
this.snapshot = { sessionId, state: draft }
|
||||
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, draft)
|
||||
this.notify()
|
||||
}
|
||||
@@ -747,7 +1015,7 @@ export class SidebarStore {
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next }
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Pure derivations for the Subagent page's background-task section. Kept
|
||||
* framework-free so the node test environment can unit-test them: the task
|
||||
* rows arrive through the harness `session/tasks` push mirror
|
||||
* (`tasksBySession` in the sessions list feed) — nothing here issues
|
||||
* requests, and the row ordering / status mapping mirror the official
|
||||
* ui-task header list.
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarTaskStatus,
|
||||
SidebarTaskView,
|
||||
} from '../context-types.ts'
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One row of the tasks section: the task plus its owning session's title. */
|
||||
export interface TreeTask {
|
||||
ownerSessionId: string
|
||||
ownerTitle: string
|
||||
task: SidebarTaskView
|
||||
}
|
||||
|
||||
/** Whether the registry still holds the task open (its duration ticks). */
|
||||
export function isTaskLive(task: SidebarTaskView): boolean {
|
||||
return task.status === 'running' || task.status === 'stopping'
|
||||
}
|
||||
|
||||
/**
|
||||
* Every session id of the topology tree rooted at `rootId` (the root plus
|
||||
* each session whose uninterrupted subagent-origin chain reaches it — same
|
||||
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
|
||||
* Sessions outside the tree (orphans, other trees) are excluded, so the
|
||||
* tasks section never shows foreign work.
|
||||
*/
|
||||
export function treeSessionIds(
|
||||
byId: SidebarSessionList['byId'],
|
||||
rootId: string | undefined,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
if (rootId === undefined) return ids
|
||||
for (const summary of Object.values(byId)) {
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = summary
|
||||
let reachesRoot = false
|
||||
while (current !== undefined && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.id === rootId) {
|
||||
reachesRoot = true
|
||||
break
|
||||
}
|
||||
if (current.origin !== 'subagent' || current.parentId === undefined) break
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
if (reachesRoot) ids.add(summary.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a NEW background task appeared for one session between two
|
||||
* consecutive list snapshots (a task id the previous snapshot lacked).
|
||||
* Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the
|
||||
* agent may start several tasks over a session, and each new one should
|
||||
* surface the Tasks page (a fresh page load never triggers — its baseline
|
||||
* starts at the current snapshot).
|
||||
*/
|
||||
export function detectNewTask(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id))
|
||||
return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the background tasks of the whole current tree, owner-labeled.
|
||||
* Sessions without a mirror entry contribute nothing; an absent mirror
|
||||
* (runtime older than the tasks feed) yields an empty list.
|
||||
*/
|
||||
export function collectTreeTasks(
|
||||
byId: SidebarSessionList['byId'],
|
||||
tasksBySession: Readonly<Record<string, readonly SidebarTaskView[]>> | undefined,
|
||||
rootId: string | undefined,
|
||||
): TreeTask[] {
|
||||
const rows: TreeTask[] = []
|
||||
if (tasksBySession === undefined) return rows
|
||||
for (const sessionId of treeSessionIds(byId, rootId)) {
|
||||
const tasks = tasksBySession[sessionId]
|
||||
if (tasks === undefined || tasks.length === 0) continue
|
||||
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
|
||||
for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rows first in start order, then settled rows newest-first (mirror of
|
||||
* the official ui-task ordering); a tie falls back to start order so the
|
||||
* sort never depends on the host's map iteration.
|
||||
*/
|
||||
export function orderTasks(rows: readonly TreeTask[]): TreeTask[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const liveLeft = isTaskLive(left.task)
|
||||
if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.task.startedAt - right.task.startedAt
|
||||
const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt)
|
||||
return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt
|
||||
})
|
||||
}
|
||||
|
||||
/** The sidebar's StateDot states for the five wire statuses. */
|
||||
export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error'
|
||||
|
||||
/**
|
||||
* Status marker semantics. `stopping` and `killed` share the attention
|
||||
* color: both mean the work ended (or is ending) on request rather than on
|
||||
* its own.
|
||||
*/
|
||||
export function taskDotState(status: SidebarTaskStatus): TaskDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'stopping': return 'warning'
|
||||
case 'completed': return 'done'
|
||||
case 'killed': return 'warning'
|
||||
case 'failed': return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Human status word of one wire status (localized through the passed translator). */
|
||||
export function taskStatusLabel(
|
||||
status: SidebarTaskStatus,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'running': return t('taskStatusRunning')
|
||||
case 'stopping': return t('taskStatusStopping')
|
||||
case 'completed': return t('taskStatusCompleted')
|
||||
case 'killed': return t('taskStatusKilled')
|
||||
case 'failed': return t('taskStatusFailed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed time in at most two adjacent units (mirror of the official
|
||||
* ui-task duration wording). A background task that outlives an hour is
|
||||
* already exceptional, so hours is the widest unit.
|
||||
*/
|
||||
export function formatTaskDuration(
|
||||
elapsedMs: number,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3_600)
|
||||
if (hours > 0) return t('taskDurationHours', { hours, minutes })
|
||||
if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds })
|
||||
return t('taskDurationSeconds', { seconds })
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The .xlsx preview component: renders via the Univer sheets preset (data +
|
||||
* formulas + formatting). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-xlsx.js`) — the Univer family and SheetJS pull in several MB
|
||||
* but are loaded only when a .xlsx is first opened. The docx stack never
|
||||
* shares this script.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the chunk by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the chunk still
|
||||
// contains all of them — the chunk itself only loads on first .xlsx).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -79,5 +79,17 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
autoOpenTasks: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
bottomPanelAutoTerminal: z.boolean().default(true),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
browserNoSandbox: z.boolean().default(false),
|
||||
browserInterceptLinks: z.boolean().default(true),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
})
|
||||
|
||||
+89
-1
@@ -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 {
|
||||
@@ -47,7 +48,15 @@ export interface SidebarSessionHeader {
|
||||
|
||||
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
|
||||
export interface SidebarSessionStore {
|
||||
get(id: string): { header: SidebarSessionHeader } | undefined
|
||||
get(id: string): {
|
||||
header: SidebarSessionHeader
|
||||
/**
|
||||
* The live session's append-only event log (immutable snapshot; absent
|
||||
* on sessions the runtime has not hydrated). Read-only access — the
|
||||
* tasks.output route replays `task_output` tool/result rows from it.
|
||||
*/
|
||||
events?: readonly SidebarSessionEvent[]
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/** One loader entry's options slice (the connection row's resolved config). */
|
||||
@@ -149,6 +158,42 @@ export interface SidebarHistoryEntry {
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** Lifecycle status set of one background task (closed wire union). */
|
||||
export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* One background task as the client mirror sees it (wire `TaskView` shape:
|
||||
* id/kind/label/status/detail?/startedAt/finishedAt?).
|
||||
*/
|
||||
export interface SidebarTaskView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
|
||||
id: string
|
||||
/** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/** Current lifecycle state. */
|
||||
status: SidebarTaskStatus
|
||||
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */
|
||||
export interface SidebarTasksService {
|
||||
/** Request cancellation; throws for an unknown or foreign task. */
|
||||
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
|
||||
}
|
||||
|
||||
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
|
||||
export interface SidebarAgentsService {
|
||||
/** The live agent registered under a session id, or undefined when not live. */
|
||||
get(id: string): SidebarAgent | undefined
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
@@ -176,6 +221,12 @@ export interface SidebarSessionList {
|
||||
byId: Record<string, SidebarSessionSummary>
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
/**
|
||||
* Background tasks per session, last-wins from the harness's `session/tasks`
|
||||
* push (a missing key is an empty set). Absent on runtime snapshots older
|
||||
* than the tasks mirror — the sidebar simply shows no task rows.
|
||||
*/
|
||||
tasksBySession?: Readonly<Record<string, readonly SidebarTaskView[]>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
@@ -231,6 +282,17 @@ export interface SidebarConversation {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client workspaces service face (mirror of the runtime IWorkspaces). Only
|
||||
* the chat's file-open funnel is touched: `openPath` hands an absolute path
|
||||
* to the Host OS's default application, and every chat-side file open
|
||||
* (tool rows, produced-files, prose mentions) funnels through it.
|
||||
*/
|
||||
export interface SidebarWorkspacesService {
|
||||
/** Open a filesystem path with the Host operating system's default application. */
|
||||
openPath(path: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
|
||||
* InvariantService). The upstream augmentation does not reach this Context
|
||||
@@ -306,9 +368,35 @@ declare module 'cordis' {
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
workspaces: SidebarWorkspacesService
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
tools: SidebarToolsService
|
||||
/**
|
||||
* The host background-task registry (`ctx.get('tasks')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
*/
|
||||
tasks: SidebarTasksService
|
||||
/**
|
||||
* The host live-agent registry (`ctx.get('agents')`; optional — used to
|
||||
* resolve the caller the tasks fence compares against).
|
||||
*/
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Subscribe to the session append feed (mirror of the cordis event API):
|
||||
* the listener receives every appended session event with the LIVE
|
||||
* Session instance that appended it. The api-proxy pushes the same feed
|
||||
* to browsers; the sidebar uses it to mirror task_output events the
|
||||
* session store's own log can lag behind (restart divergence). Returns
|
||||
* the disposer.
|
||||
*/
|
||||
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
* activation; its returned cleanup runs at disposal.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
|
||||
*
|
||||
* Why path-encoded parameters instead of a query string: the previewed
|
||||
* page resolves its relative assets (./style.css, img/x.png) against the
|
||||
* document URL, and the WHATWG URL algorithm DROPS the query of a
|
||||
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
|
||||
* would lose the session scope and the route would reject the asset.
|
||||
* Encoding everything into the URL path keeps relative resolution inside
|
||||
* the same route with every request self-contained:
|
||||
*
|
||||
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
|
||||
* /sidebar/html/S/Users/me/proj/index.html
|
||||
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
|
||||
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
||||
*
|
||||
* This module is intentionally dependency-free (no node imports, no wire
|
||||
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
|
||||
* the build-time purity gate; the host converts decode failures into
|
||||
* SidebarError responses at the route boundary.
|
||||
*/
|
||||
|
||||
/** One decoded route reference. */
|
||||
export interface HtmlRouteRef {
|
||||
sessionId: string
|
||||
/** Absolute file path (leading slash; Windows drives keep their colon). */
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Decode outcome: the reference, or a client-error description. */
|
||||
export type HtmlDecodeResult =
|
||||
| { ok: true; ref: HtmlRouteRef }
|
||||
| { ok: false; status: 400 | 404; message: string }
|
||||
|
||||
/** The route prefix both encoders/decoders agree on. */
|
||||
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
|
||||
|
||||
/** Build the route URL for one absolute file path (client + tests). */
|
||||
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
||||
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
||||
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a route pathname into the session + absolute file path. Rejects
|
||||
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
||||
* encoding, and a missing sessionId or file path (400). The caller still
|
||||
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
||||
* decoded `..` segment resolves outside the cwd and is refused there.
|
||||
*/
|
||||
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
||||
return { ok: false, status: 404, message: 'not an html route' }
|
||||
}
|
||||
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
||||
if (rest === '' || rest.includes('//')) {
|
||||
return { ok: false, status: 400, message: 'invalid html route path' }
|
||||
}
|
||||
let segments: string[]
|
||||
try {
|
||||
segments = rest.split('/').map(segment => decodeURIComponent(segment))
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
||||
}
|
||||
const [sessionId, ...pathSegments] = segments
|
||||
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
return { ok: true, ref: { sessionId, path: `/${pathSegments.join('/')}` } }
|
||||
}
|
||||
+179
-12
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
||||
* read/write, git), the /sidebar/file media route (images), and the terminal
|
||||
* WebSocket upgrade. Every route passes the same browser-trust fence as the
|
||||
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
|
||||
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
|
||||
* trustedHosts read live from the connection loader row so the fence never
|
||||
* drifts from the deployment's.
|
||||
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
|
||||
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
|
||||
* and the terminal WebSocket upgrade. Every route passes the same
|
||||
* browser-trust fence as the /api gateway — Host-header loopback or the
|
||||
* connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
|
||||
* literals per boot) — with the trustedHosts read live from the connection
|
||||
* loader row so the fence never drifts from the deployment's.
|
||||
*
|
||||
* All operations are conversation-scoped: requests carry a sessionId, the
|
||||
* session's authoritative cwd comes from the session store, and terminal
|
||||
@@ -26,16 +27,33 @@ import {
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
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'
|
||||
@@ -55,6 +73,8 @@ const MEDIA_TYPES: Record<string, string> = {
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.pdf': 'application/pdf',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
}
|
||||
|
||||
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
||||
@@ -110,8 +130,19 @@ async function resolveGitPath(cwd: string, raw: string): Promise<string> {
|
||||
return requireAbsolute(join(root, raw))
|
||||
}
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe. */
|
||||
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
|
||||
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
||||
const READ_HEAD_LIMIT = 4096
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe.
|
||||
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
||||
* so the client can re-match viewers by content (`detect`). */
|
||||
async function readText(path: string, readLimit: number): Promise<{
|
||||
content: string
|
||||
truncated: boolean
|
||||
binary: boolean
|
||||
size: number
|
||||
head?: string
|
||||
}> {
|
||||
const info = await stat(path).catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
@@ -128,7 +159,10 @@ async function readText(path: string, readLimit: number): Promise<{ content: str
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
||||
const slice = buffer.subarray(0, bytesRead)
|
||||
const binary = slice.includes(0)
|
||||
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
|
||||
const head = binary
|
||||
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
|
||||
: undefined
|
||||
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -165,6 +199,12 @@ function buildApi(
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
// Background tasks: the LIST rides the harness's `session/tasks` push
|
||||
// mirror, so these routes only replay output the model has read (from the
|
||||
// session's own event log — no DSH source is touched, the model's
|
||||
// task_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the tasks registry downgrades kill to a 503.
|
||||
const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit)
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
@@ -181,8 +221,8 @@ function buildApi(
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
// names; the untracked diff view reads the file through this route).
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated }
|
||||
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated, head }
|
||||
return { kind: 'text', content, truncated }
|
||||
},
|
||||
'fs.write': async (payload) => {
|
||||
@@ -294,6 +334,14 @@ function buildApi(
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Background tasks: read one task's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// task_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one task. The task LIST itself arrives
|
||||
// through the harness's session/tasks push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the tasks registry.
|
||||
'tasks.output': (payload) => tasksApi.output(payload),
|
||||
'tasks.kill': (payload) => tasksApi.kill(payload),
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
@@ -323,6 +371,55 @@ function buildApi(
|
||||
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
||||
}
|
||||
},
|
||||
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
|
||||
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
|
||||
// signals the browser enforces when it refuses to embed a site. The
|
||||
// probe is display-only (headers back to the caller), restricted to
|
||||
// http(s) non-loopback URLs with a hard timeout, and gated by the same
|
||||
// trust fence as every other route — a cross-site page cannot reach it.
|
||||
'browser.probe': async (payload) => {
|
||||
const raw = requireString(payload, 'url')
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', 'invalid url', 400)
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
|
||||
}
|
||||
// Mirror the browser tab's address-bar policy: loopback stays unreachable
|
||||
// from the sidebar, so probing it would leak nothing the tab could use.
|
||||
if (isLoopbackHostname(parsed.hostname)) {
|
||||
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
|
||||
// Some servers answer HEAD with 405/501; retry once as GET (the
|
||||
// body is discarded — only the headers matter).
|
||||
if (response.status === 405 || response.status === 501) {
|
||||
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
|
||||
}
|
||||
const csp = response.headers.get('content-security-policy')
|
||||
const frameAncestors = extractFrameAncestors(csp)
|
||||
const xFrameOptions = response.headers.get('x-frame-options')
|
||||
return {
|
||||
reachable: true,
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
...(xFrameOptions !== null ? { xFrameOptions } : {}),
|
||||
...(frameAncestors !== undefined ? { frameAncestors } : {}),
|
||||
}
|
||||
} catch {
|
||||
// DNS / TLS / connection / timeout: nothing to judge — the client
|
||||
// keeps the plain iframe.
|
||||
return { reachable: false }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +534,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/api routes')
|
||||
|
||||
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
|
||||
// Serves the client half's split bundles (lib/client-<name>.js) so the
|
||||
// heavy preview/terminal libraries load on first use, not at page start
|
||||
// (see bundle-route.ts / src/client/chunk-loader.ts).
|
||||
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
||||
|
||||
// ── Media route (images for the editor) ─────────────────────────────────
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
@@ -486,6 +589,70 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/file media route')
|
||||
|
||||
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
|
||||
// Serves files under the session cwd for the built-in HTML previewer. The
|
||||
// URL is path-encoded (see html-route.ts) so the previewed page's relative
|
||||
// assets (./style.css, img/x.png) resolve back into this route with the
|
||||
// session scope intact — a query-encoded URL would drop the scope when the
|
||||
// browser resolves relatives. Every response carries the CSP `sandbox`
|
||||
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
|
||||
// boundary, this header is defense-in-depth so even a top-level load of
|
||||
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
|
||||
// origin with no same-origin access to the GUI.
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/html',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const decoded = decodeHtmlUrl(url.pathname)
|
||||
if (!decoded.ok) {
|
||||
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
|
||||
return
|
||||
}
|
||||
const { sessionId, path } = decoded.ref
|
||||
// The session's authoritative cwd (client cwd cannot ride in the URL
|
||||
// — the path encoding has no query; a detached first request falls
|
||||
// back to the process cwd and is normally refused by isWithin, same
|
||||
// semantics as the media route's fallback).
|
||||
const cwd = sessionCwdOf(ctx, sessionId)
|
||||
const absolute = requireAbsolute(path)
|
||||
if (!isWithin(cwd, absolute)) {
|
||||
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
|
||||
}
|
||||
const info = await stat(absolute)
|
||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||
}
|
||||
const type = mediaTypeForPath(absolute)
|
||||
const body = await readFile(absolute)
|
||||
res.writeHead(200, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-cache',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'no-referrer',
|
||||
// The sandbox directive (no allow-same-origin → opaque origin) is
|
||||
// the previewer's security boundary even for top-level loads;
|
||||
// object-src 'none' blocks plugin embeds.
|
||||
'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
|
||||
})
|
||||
res.end(body)
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/html preview route')
|
||||
|
||||
// ── Terminal WebSocket ──────────────────────────────────────────────────
|
||||
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
|
||||
// agent-owned terminals (?uuid=...). The two paths attach to different
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
|
||||
* Subagent page when the current conversation spawns a new subagent.
|
||||
*/
|
||||
autoOpenSubagent: boolean
|
||||
/**
|
||||
* Whether the sidebar auto-activates (opens the panel) and expands the
|
||||
* Tasks page when a NEW background task appears for the current
|
||||
* conversation (any new task id, not just the first one).
|
||||
*/
|
||||
autoOpenTasks: boolean
|
||||
/**
|
||||
* Whether the model-facing agent terminal tools (terminal_create / list /
|
||||
* send / read / wait_for / resize / signal / close) are injected into the
|
||||
@@ -27,6 +33,70 @@ export interface SidebarPrefs {
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Whether expanding the bottom panel for the FIRST time in a session tries
|
||||
* to open a fresh terminal tab there (the terminal quota/type still gates
|
||||
* the attempt). On by default; the switch lives under the terminal tab's
|
||||
* row in the Side card settings.
|
||||
*/
|
||||
bottomPanelAutoTerminal: boolean
|
||||
/**
|
||||
* Whether chat-side file opens (tool-row path links, the produced-files
|
||||
* row, prose file mentions — every path that funnels through the client
|
||||
* runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
|
||||
* of the Host OS's default application. On by default; the editor tab's
|
||||
* own enable switch gates it too (both must be on for the takeover).
|
||||
*/
|
||||
interceptOpenPath: boolean
|
||||
/**
|
||||
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
|
||||
* default) renders previewed HTML in an opaque-origin iframe that cannot
|
||||
* touch the GUI; turning it OFF runs the previewed page with the GUI's
|
||||
* own origin — full read/write access to session files and internal
|
||||
* APIs. Only for trusted local content; the setting copy warns.
|
||||
*/
|
||||
htmlViewerNoSandbox: boolean
|
||||
/**
|
||||
* Whether a newly opened HTML preview starts UNSANDBOXED (the per-surface
|
||||
* temporary unlock pre-applied). Off by default: previews open sandboxed
|
||||
* and the status row offers the one-tap unlock; when on, previews open
|
||||
* in the red unsandboxed state and the status row offers a one-tap
|
||||
* restore for the current file.
|
||||
*/
|
||||
htmlViewerDefaultUnsafe: boolean
|
||||
/**
|
||||
* Whether the browser tab drops its sandboxed iframe. Sandbox ON (the
|
||||
* default) keeps browsed sites in an opaque origin with no GUI access;
|
||||
* turning it OFF runs any visited site with the GUI's own origin — it
|
||||
* can read session data and act as the logged-in GUI. Only for trusted
|
||||
* sites; the setting copy warns.
|
||||
*/
|
||||
browserNoSandbox: boolean
|
||||
/**
|
||||
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
|
||||
* tool rows, prose mentions) opens the sidebar browser instead of a new
|
||||
* browser tab. On by default; gated on the browser tab's own enable
|
||||
* switch (both must be on for the takeover). Ctrl/Cmd+click always
|
||||
* bypasses the takeover.
|
||||
*/
|
||||
browserInterceptLinks: boolean
|
||||
/**
|
||||
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
|
||||
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
|
||||
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
|
||||
* and derived flows like subagent auto-open / agent-terminal tabs stop).
|
||||
* Already-open tabs of a disabled type keep rendering (closing one
|
||||
* prevents reopening), matching the "existing conversations keep their
|
||||
* own layouts" rule.
|
||||
*/
|
||||
tabsEnabled: Record<string, boolean>
|
||||
/**
|
||||
* Per-viewer enable switches, keyed by file viewer descriptor id
|
||||
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
|
||||
* viewer is skipped by `matchFileViewer` so files fall through to the
|
||||
* next matching viewer (or the download button when none match).
|
||||
*/
|
||||
viewersEnabled: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
@@ -39,7 +109,16 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Background-task routes of the /sidebar JSON API ('tasks.output' /
|
||||
* 'tasks.kill'). The task LIST needs no route: it arrives through the
|
||||
* harness's `session/tasks` push mirror (`tasksBySession` in the sessions
|
||||
* list feed). The routes:
|
||||
*
|
||||
* - 'tasks.output' — REPLAYS the output the MODEL has read so far for one
|
||||
* task. The source is the owner session's own event log: `tool/call` rows
|
||||
* of `task_output` name the task via `arguments.task_id`, and the paired
|
||||
* `tool/result` rows carry the finalized content the model received.
|
||||
* Because the session store's in-memory log can lag the live append feed
|
||||
* after a host restart (the store session stays frozen at its
|
||||
* rehydration boundary), the plugin ALSO mirrors task_output events from
|
||||
* the live `session/event` feed and merges both sources (deduped by seq).
|
||||
* This touches NO DSH source: the model's `task_output` cursor is never
|
||||
* consumed, and the pane stays empty until the agent reads the task.
|
||||
* - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API),
|
||||
* fenced by the owning session via the live agent caller. Absent registry
|
||||
* → 503, mirroring the settings routes' optional-service downgrade.
|
||||
*/
|
||||
import type { Context, SidebarSessionEvent } from './context-types.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The two background-task routes of the sidebar API. */
|
||||
export interface SidebarTasksRoutes {
|
||||
/** The output the model has read so far for one task (event replay, capped). */
|
||||
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
||||
/** Request cancellation of one task (live tasks flip to stopping). */
|
||||
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
|
||||
}
|
||||
|
||||
/** The 'tool/result' message envelope inside a session event's data. */
|
||||
interface ToolResultMessageLike {
|
||||
source?: { kind?: unknown; callId?: unknown }
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
/** One 'tool-result' content block (the inner blocks carry the text). */
|
||||
interface ToolResultBlockLike {
|
||||
type?: unknown
|
||||
content?: unknown
|
||||
isError?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the plain text of a finalized tool result: the text blocks inside
|
||||
* the 'tool-result' block, joined with newlines. Error results and
|
||||
* non-text blocks contribute nothing.
|
||||
*/
|
||||
function resultText(message: ToolResultMessageLike): string | undefined {
|
||||
if (!Array.isArray(message.content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of message.content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as ToolResultBlockLike
|
||||
if (candidate.type !== 'tool-result') continue
|
||||
const inner = candidate.content
|
||||
if (!Array.isArray(inner)) continue
|
||||
for (const item of inner) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** Whether a tool/result is an error result (the inner block's isError flag). */
|
||||
function resultIsError(message: ToolResultMessageLike): boolean {
|
||||
if (!Array.isArray(message.content)) return false
|
||||
return message.content.some((block) => {
|
||||
if (block === null || typeof block !== 'object') return false
|
||||
return (block as ToolResultBlockLike).type === 'tool-result'
|
||||
&& (block as ToolResultBlockLike).isError === true
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a task_output result carries no new output — the controller's
|
||||
* model-facing "(no new output)" body, noise for the human pane. */
|
||||
function isNoNewOutput(text: string): boolean {
|
||||
return text.startsWith('(no new output)')
|
||||
}
|
||||
|
||||
/** One compact task_output trace (a tool/call or its paired tool/result). */
|
||||
interface TaskOutputTrace {
|
||||
seq: number
|
||||
kind: 'call' | 'result'
|
||||
/** The tool call identity pairing the two rows. */
|
||||
callId: string
|
||||
/** tool/call: the task id parsed from the model arguments. */
|
||||
taskId?: string
|
||||
/** tool/result: the finalized text the model received. */
|
||||
text?: string
|
||||
/** tool/result: whether the result was an error (read counts, text skipped). */
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Extract the task_output trace of one raw session event (undefined = unrelated). */
|
||||
function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
||||
if (data.name !== 'task_output' || typeof data.callId !== 'string') return undefined
|
||||
let taskId: string | undefined
|
||||
try {
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown }
|
||||
if (typeof args.task_id === 'string') taskId = args.task_id
|
||||
} catch {
|
||||
// Malformed model arguments: not a task_output pair.
|
||||
}
|
||||
if (taskId === undefined) return undefined
|
||||
return { seq: event.seq, kind: 'call', callId: data.callId, taskId }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
|
||||
if (message === undefined) return undefined
|
||||
const callId = message.source?.callId
|
||||
if (typeof callId !== 'string') return undefined
|
||||
return {
|
||||
seq: event.seq,
|
||||
kind: 'result',
|
||||
callId,
|
||||
text: resultText(message),
|
||||
isError: resultIsError(message),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
|
||||
const MIRROR_MAX_ENTRIES = 200
|
||||
|
||||
/**
|
||||
* The live task_output mirror: subscribes to the session append feed and
|
||||
* caches the task_output traces the session store's own log can lag behind
|
||||
* (after a host restart the store session stays frozen at its rehydration
|
||||
* boundary, so `session.events` misses everything appended since — the very
|
||||
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
||||
* same feed to browsers.
|
||||
*/
|
||||
function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): readonly TaskOutputTrace[] } {
|
||||
const perSession = new Map<string, TaskOutputTrace[]>()
|
||||
// tool/call identities per session, so unrelated tool/result rows are
|
||||
// never cached (only task_output results pair with a cached call).
|
||||
const callIds = new Map<string, Set<string>>()
|
||||
if (typeof ctx.on !== 'function') {
|
||||
// Test doubles without the event API degrade to seed-only replay.
|
||||
return { entries: () => [] }
|
||||
}
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
const sessionId = (session as { id?: unknown } | null)?.id
|
||||
if (typeof sessionId !== 'string') return
|
||||
if (event.type === 'tool/call') {
|
||||
const trace = traceOf(event)
|
||||
if (trace?.kind !== 'call') return
|
||||
let ids = callIds.get(sessionId)
|
||||
if (ids === undefined) callIds.set(sessionId, ids = new Set())
|
||||
ids.add(trace.callId)
|
||||
push(sessionId, trace)
|
||||
} else if (event.type === 'tool/result') {
|
||||
const trace = traceOf(event)
|
||||
if (trace?.kind !== 'result') return
|
||||
if (!callIds.get(sessionId)?.has(trace.callId)) return
|
||||
push(sessionId, trace)
|
||||
}
|
||||
})
|
||||
ctx.effect(() => dispose, 'dsh-better-sidebar: task-output event mirror')
|
||||
|
||||
const push = (sessionId: string, trace: TaskOutputTrace): void => {
|
||||
let list = perSession.get(sessionId)
|
||||
if (list === undefined) perSession.set(sessionId, list = [])
|
||||
list.push(trace)
|
||||
if (list.length > MIRROR_MAX_ENTRIES) {
|
||||
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES)
|
||||
const ids = callIds.get(sessionId)
|
||||
if (ids !== undefined) {
|
||||
for (const entry of removed) {
|
||||
if (entry.kind === 'call') ids.delete(entry.callId)
|
||||
}
|
||||
if (ids.size === 0) callIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tasks routes bound to the plugin context. `output` merges the
|
||||
* owner session's own event log with the live task_output mirror; `kill`
|
||||
* reads the tasks/agents services lazily and degrades to a 503 when the
|
||||
* deployment lacks the registry.
|
||||
* @param ctx - host plugin context.
|
||||
* @param outputLimit - response cap for one output replay in bytes; longer
|
||||
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
||||
*/
|
||||
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes {
|
||||
const tasks = ctx.get('tasks')
|
||||
const agents = ctx.get('agents')
|
||||
const mirror = createTaskOutputMirror(ctx)
|
||||
/** The live caller whose session id the registry fence compares against. */
|
||||
const callerOf = (sessionId: string) => agents?.get(sessionId)
|
||||
/** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */
|
||||
const registryError = (error: unknown): SidebarError =>
|
||||
new SidebarError('task-error', error instanceof Error ? error.message : String(error), 404)
|
||||
return {
|
||||
output(payload) {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
// Merge the store's event log (durable seed + whatever it received)
|
||||
// with the live mirror, deduped by seq — a trace never double-counts.
|
||||
const bySeq = new Map<number, TaskOutputTrace>()
|
||||
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
|
||||
const trace = traceOf(event)
|
||||
if (trace !== undefined) bySeq.set(trace.seq, trace)
|
||||
}
|
||||
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
|
||||
// Pair calls with results in seq order: the model's reads, oldest first.
|
||||
const taskOf = new Map<string, string>()
|
||||
const parts: string[] = []
|
||||
let read = false
|
||||
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
|
||||
if (trace.kind === 'call') {
|
||||
if (trace.taskId !== undefined) taskOf.set(trace.callId, trace.taskId)
|
||||
} else if (taskOf.get(trace.callId) === id) {
|
||||
read = true
|
||||
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
|
||||
parts.push(trace.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = parts.join('\n')
|
||||
return {
|
||||
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
||||
truncated: text.length > outputLimit,
|
||||
read,
|
||||
}
|
||||
},
|
||||
kill(payload) {
|
||||
if (tasks === undefined) {
|
||||
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
|
||||
}
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
const record = payload as { reason?: unknown } | null
|
||||
const reason = typeof record?.reason === 'string' && record.reason !== ''
|
||||
? record.reason
|
||||
: 'user requested via sidebar'
|
||||
try {
|
||||
return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) }
|
||||
} catch (error) {
|
||||
throw registryError(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'task-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
|
||||
* keys off (paired with the CSS @media (max-width: 767px) gates in
|
||||
* sidebar.module.css — 767px ≡ widths strictly below NARROW_MAX_WIDTH).
|
||||
* Deliberately NOT aligned to the DSH app shell's 1024px breakpoint: the
|
||||
* mobile layout is for real narrow screens (phones / portrait tablets).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
|
||||
|
||||
describe('narrow-viewport breakpoint', () => {
|
||||
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
|
||||
expect(NARROW_MAX_WIDTH).toBe(768)
|
||||
expect(isNarrowWidth(320)).toBe(true)
|
||||
expect(isNarrowWidth(390)).toBe(true)
|
||||
expect(isNarrowWidth(600)).toBe(true)
|
||||
expect(isNarrowWidth(767)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
|
||||
expect(isNarrowWidth(768)).toBe(false)
|
||||
expect(isNarrowWidth(1024)).toBe(false)
|
||||
expect(isNarrowWidth(1280)).toBe(false)
|
||||
expect(isNarrowWidth(1920)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Browser globals for specs that pull browser-bound modules. Import this
|
||||
* FIRST in a spec file: unlike statements after imports (which vitest
|
||||
* hoists above the module body), an import statement evaluates in order,
|
||||
* so this runs before any later import's module graph (xterm's UMD wrapper
|
||||
* checks `self` and its color defaults probe `document` at evaluation time;
|
||||
* window/localStorage are only touched at runtime by the store).
|
||||
*/
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
if (g.self === undefined) g.self = globalThis
|
||||
|
||||
const elementStub = (): Record<string, unknown> => ({
|
||||
style: {},
|
||||
dataset: {},
|
||||
textContent: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
setAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
hasAttribute: () => false,
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
|
||||
// xterm's color palette probe measures a canvas context at module load.
|
||||
getContext: () => null,
|
||||
})
|
||||
|
||||
if (g.document === undefined) {
|
||||
g.document = {
|
||||
createElement: () => elementStub(),
|
||||
createDocumentFragment: () => elementStub(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
defaultView: globalThis,
|
||||
body: elementStub(),
|
||||
// CodeMirror's view module probes the UA and injects styles at load;
|
||||
// the plugin's css-inline prologue probes for existing style tags.
|
||||
documentElement: elementStub(),
|
||||
head: elementStub(),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
styleSheets: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (g.navigator === undefined) {
|
||||
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
|
||||
}
|
||||
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
innerHeight: 768,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* browser.probe helpers: the CSP frame-ancestors parser (host side) and the
|
||||
* embeddability verdict (client side). Together they turn the target's
|
||||
* response headers into the "refused to be embedded" explanation the
|
||||
* sidebar browser shows instead of the browser's cryptic blank frame.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractFrameAncestors } from '../src/browser-probe.ts'
|
||||
import { embeddabilityOf } from '../src/client/browser.ts'
|
||||
|
||||
describe('extractFrameAncestors', () => {
|
||||
it('extracts a frame-ancestors source list from a CSP header', () => {
|
||||
expect(extractFrameAncestors("default-src 'self'; frame-ancestors 'none'"))
|
||||
.toEqual(["'none'"])
|
||||
expect(extractFrameAncestors("frame-ancestors 'self' https://example.com"))
|
||||
.toEqual(["'self'", 'https://example.com'])
|
||||
expect(extractFrameAncestors('frame-ancestors *')).toEqual(['*'])
|
||||
})
|
||||
|
||||
it('is undefined when the directive is absent or empty', () => {
|
||||
expect(extractFrameAncestors(null)).toBeUndefined()
|
||||
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined()
|
||||
expect(extractFrameAncestors('frame-ancestors')).toBeUndefined()
|
||||
expect(extractFrameAncestors('frame-ancestors ; default-src *')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('embeddabilityOf', () => {
|
||||
it('blocks X-Frame-Options DENY / SAMEORIGIN', () => {
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'DENY' })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'SAMEORIGIN' })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'sameorigin' })).toBe('blocked')
|
||||
})
|
||||
|
||||
it('blocks a frame-ancestors list that does not allow * ("self" means the SITE origin, never ours)', () => {
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'none'"] })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'"] })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'", 'https://x.dev'] })).toBe('blocked')
|
||||
})
|
||||
|
||||
it('allows a wildcard frame-ancestors and absent anti-framing headers', () => {
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, frameAncestors: ['*'] })).toBe('embeddable')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200 })).toBe('embeddable')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'ALLOW-FROM https://x' })).toBe('embeddable')
|
||||
})
|
||||
|
||||
it('is unknown when the target is unreachable (plain iframe stays)', () => {
|
||||
expect(embeddabilityOf({ reachable: false })).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Browser address-bar policy tests: only http(s) URLs may be navigated,
|
||||
* loopback addresses and the GUI's own origin are refused outright. The
|
||||
* iframe sandbox (opaque origin) is the primary security boundary; this
|
||||
* policy is the address-bar gate on top of it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostname, normalizeBrowserUrl } from '../src/client/browser.ts'
|
||||
|
||||
const SELF = 'http://127.0.0.1:3080'
|
||||
|
||||
describe('normalizeBrowserUrl', () => {
|
||||
it('normalizes a bare domain to https', () => {
|
||||
expect(normalizeBrowserUrl('example.com', SELF)).toEqual({ kind: 'ok', url: 'https://example.com/' })
|
||||
})
|
||||
|
||||
it('normalizes a host with a port to https', () => {
|
||||
expect(normalizeBrowserUrl('example.com:8080/path', SELF)).toEqual({ kind: 'ok', url: 'https://example.com:8080/path' })
|
||||
})
|
||||
|
||||
it('keeps an explicit http:// scheme', () => {
|
||||
expect(normalizeBrowserUrl('http://example.com/a?b=1', SELF)).toEqual({ kind: 'ok', url: 'http://example.com/a?b=1' })
|
||||
})
|
||||
|
||||
it('accepts a non-loopback IP literal', () => {
|
||||
expect(normalizeBrowserUrl('https://8.8.8.8/dns', SELF)?.kind).toBe('ok')
|
||||
})
|
||||
|
||||
it('refuses non-http(s) schemes', () => {
|
||||
expect(normalizeBrowserUrl('javascript:alert(1)', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('data:text/html,<b>x</b>', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('file:///etc/passwd', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('about:blank', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
})
|
||||
|
||||
it('refuses loopback hostnames in every spelling', () => {
|
||||
for (const input of [
|
||||
'http://localhost/', 'https://localhost:3080/', 'http://LOCALHOST/',
|
||||
'http://127.0.0.1/', 'http://127.255.255.255/',
|
||||
'http://[::1]/', 'http://0.0.0.0/',
|
||||
]) {
|
||||
expect(normalizeBrowserUrl(input, SELF), input).toEqual({ kind: 'blocked', reason: 'loopback' })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows the GUI\'s own origin (the sandbox keeps it opaque like any site)', () => {
|
||||
// The user may browse the GUI itself in the sidebar; its host is
|
||||
// loopback, so the self check must win BEFORE the loopback gate.
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:3080/sidebar', SELF)).toEqual({
|
||||
kind: 'ok', url: 'http://127.0.0.1:3080/sidebar',
|
||||
})
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:3080/', SELF)).toEqual({
|
||||
kind: 'ok', url: 'http://127.0.0.1:3080/',
|
||||
})
|
||||
// A different port of the same loopback host is NOT the GUI origin and
|
||||
// stays blocked.
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:9999/', SELF)).toEqual({ kind: 'blocked', reason: 'loopback' })
|
||||
})
|
||||
|
||||
it('reports invalid input', () => {
|
||||
expect(normalizeBrowserUrl('', SELF)).toEqual({ kind: 'invalid' })
|
||||
expect(normalizeBrowserUrl(' ', SELF)).toEqual({ kind: 'invalid' })
|
||||
expect(normalizeBrowserUrl('ht tp://x', SELF)).toEqual({ kind: 'invalid' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoopbackHostname', () => {
|
||||
it('flags localhost, IPv6 loopback, and the 127/8 block', () => {
|
||||
expect(isLoopbackHostname('localhost')).toBe(true)
|
||||
expect(isLoopbackHostname('::1')).toBe(true)
|
||||
expect(isLoopbackHostname('[::1]')).toBe(true)
|
||||
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHostname('127.8.9.10')).toBe(true)
|
||||
expect(isLoopbackHostname('0.0.0.0')).toBe(true)
|
||||
expect(isLoopbackHostname('example.com')).toBe(false)
|
||||
expect(isLoopbackHostname('128.0.0.1')).toBe(false)
|
||||
expect(isLoopbackHostname('192.168.1.1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 9 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html/browser sandbox settings pin the registry's behavior.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { allLeaves } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerBuiltins({} as Context, service)
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
|
||||
})
|
||||
|
||||
it('single-instance tabs use the single sugar', () => {
|
||||
const { service } = setup()
|
||||
for (const id of ['explorer', 'git', 'subagent']) {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenTasks'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools related setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal'])
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('browser')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
|
||||
const { service, store } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'browser' })
|
||||
service.openTab({ type: 'browser' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('browser:1')
|
||||
expect(tabs[1]!.id).toBe('browser:2')
|
||||
expect(state.nextBrowser).toBe(3)
|
||||
})
|
||||
|
||||
it('every built-in tab carries the settings-surface icon', () => {
|
||||
const { service } = setup()
|
||||
for (const tab of service.getTabs()) {
|
||||
expect(tab.icon, tab.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 9 built-in file viewers', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
)
|
||||
})
|
||||
|
||||
it('code is the catch-all at the lowest priority', () => {
|
||||
const { service } = setup()
|
||||
const code = service.getFileViewers().find(v => v.id === 'code')
|
||||
expect(code?.exts).toEqual([])
|
||||
expect(code?.priority).toBe(-100)
|
||||
expect(code?.fetchStrategy).toBe('fsRead')
|
||||
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('markdown claims md/markdown before the catch-all', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
|
||||
})
|
||||
|
||||
it('html claims html/htm before the catch-all', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('index.html')?.id).toBe('html')
|
||||
expect(service.matchFileViewer('page.htm')?.id).toBe('html')
|
||||
expect(service.matchFileViewer('index.html', new Uint8Array([0x3c, 0x21]))?.id).toBe('html')
|
||||
expect(service.matchFileViewer('index.HTML')?.id).toBe('html')
|
||||
})
|
||||
|
||||
it('the html viewer declares its sandbox and default-unsafe related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getFileViewers().find(v => v.id === 'html')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||||
})
|
||||
|
||||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||||
const { service } = setup()
|
||||
// First match (no head) falls to the catch-all code viewer...
|
||||
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
|
||||
// ...but the head re-match (NUL probe) routes it to binary-download.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
|
||||
// A NUL-free blob stays with code.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
|
||||
const { service } = setup()
|
||||
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
|
||||
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
|
||||
const { service } = setup()
|
||||
for (const viewer of service.getFileViewers()) {
|
||||
expect(viewer.title, viewer.id).toBeDefined()
|
||||
expect(viewer.icon, viewer.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in disposer', () => {
|
||||
it('unregisters everything (HMR-safe)', () => {
|
||||
const { service, dispose } = setup()
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
// The disposer is idempotent.
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Lazy chunk route tests (src/bundle-route.ts): the /sidebar/bundle handler
|
||||
* that serves the client's split bundles. Pins the trust fence, the name
|
||||
* allowlist (no traversal), method gating, and the caching contract — ETag
|
||||
* + If-None-Match 304 so multi-MB chunks revalidate cheaply instead of
|
||||
* re-downloading.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { CHUNK_NAMES, createBundleRouteHandler } from '../src/bundle-route.ts'
|
||||
|
||||
interface FakeRes {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
writeHead(status: number, headers?: Record<string, string>): void
|
||||
end(body?: string | Buffer): void
|
||||
}
|
||||
|
||||
function fakeRes(): FakeRes {
|
||||
return {
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: '',
|
||||
writeHead(status, headers = {}) {
|
||||
this.status = status
|
||||
this.headers = headers
|
||||
},
|
||||
end(body) {
|
||||
if (body !== undefined) this.body = body.toString()
|
||||
},
|
||||
} as FakeRes
|
||||
}
|
||||
|
||||
function req(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
|
||||
return { method, url, headers } as unknown as IncomingMessage
|
||||
}
|
||||
|
||||
/** One handler instance over a scratch dir with a fake chunk file. */
|
||||
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 0;')
|
||||
const handler = createBundleRouteHandler(() => true, dir)
|
||||
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
|
||||
}
|
||||
|
||||
describe('/sidebar/bundle route', () => {
|
||||
it('serves an allowlisted chunk with the JS content type and an ETag', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||
expect(res.headers['cache-control']).toBe('no-cache')
|
||||
expect(res.headers.etag).toMatch(/^"[0-9a-f]{12}"$/)
|
||||
expect(res.body).toContain('window.__ModuleLoader__')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('revalidates with a 304 when If-None-Match matches (no re-download)', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
const etag = first.headers.etag!
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(304)
|
||||
expect(second.body).toBe('')
|
||||
expect(second.headers.etag).toBe(etag)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the new body after the file changed (ETag rotates)', async () => {
|
||||
const { handler, dir, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 1;')
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(200)
|
||||
expect(second.headers.etag).not.toBe(first.headers.etag)
|
||||
expect(second.body).toContain('&& 1')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown chunk names (including traversal attempts) with 404', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
for (const url of ['/sidebar/bundle/evil.js', '/sidebar/bundle/xlsx', '/sidebar/bundle/../secret.js', '/sidebar/bundle/xlsx.js/extra']) {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', url), res as unknown as ServerResponse)
|
||||
expect(res.status, url).toBe(404)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('404s an allowlisted name whose built file is missing', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(404)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('gates non-GET/HEAD methods with 405', async () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(405)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces the browser-trust fence with 403 before any lookup', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-fence-'))
|
||||
try {
|
||||
const handler = createBundleRouteHandler(() => false, dir)
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(403)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Built chunk artifact contract: each lib/client-<name>.js must, when
|
||||
* executed as a classic script, assign its factory to the plugin-owned
|
||||
* global registry (globalThis.__dshChunks__[<name>]), and the factory must
|
||||
* be callable with a require that resolves the platform externals — the
|
||||
* exact shape the loader (src/client/chunk-loader.ts) depends on. Reads the
|
||||
* built lib/ output, so run `pnpm build` first (like manifest-consistency).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// Browser globals first: chunk bodies probe `self`/`document` at evaluation
|
||||
// (xterm's UMD wrapper, CodeMirror's UA probe).
|
||||
import './browser-globals.ts'
|
||||
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
g.window = g // classic-script globals
|
||||
for (const name of CHUNKS) {
|
||||
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
|
||||
// eslint-disable-next-line no-new-func
|
||||
expect(() => new Function(code)(), name).not.toThrow()
|
||||
const registry = g.__dshChunks__ as Record<string, unknown>
|
||||
expect(typeof registry[name], name).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('each chunk factory materializes through a require over the platform externals', () => {
|
||||
const registry = g.__dshChunks__ as Record<string, unknown>
|
||||
const table = new Map<string, unknown>(CHUNK_EXTERNALS.map(spec => [spec, { spec }]))
|
||||
for (const name of CHUNKS) {
|
||||
const factory = registry[name] as (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
expect(() => factory((spec) => {
|
||||
if (!table.has(spec)) throw new Error(`require("${spec}") missed the module table`)
|
||||
return table.get(spec)
|
||||
}), name).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Chunk loader tests: the lazy chunk machinery (src/client/chunk-loader.ts).
|
||||
* Pins the caching contract that makes the lazy mechanism correct:
|
||||
* - one in-flight promise per chunk (concurrent opens share it),
|
||||
* - a failed load clears the cache so the next call retries (the script
|
||||
* re-executes and overwrites its global registry slot — assignments are
|
||||
* idempotent, no duplicate-registration class of errors),
|
||||
* - externals resolve through the module system's seed branch (the stable,
|
||||
* version-independent part), once per page,
|
||||
* - resetChunks drops the cache and the externals memo (HMR).
|
||||
* The production path runs against a fake `window.__DSH_MODULES__` and a
|
||||
* stub script loader that simulates the executed chunk script by assigning
|
||||
* the plugin-owned global factory registry.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import {
|
||||
CHUNK_EXTERNALS,
|
||||
loadChunk,
|
||||
registerChunkForTests,
|
||||
resetChunks,
|
||||
setChunkScriptLoaderForTests,
|
||||
} from '../src/client/chunk-loader.ts'
|
||||
import type { ChunkExports } from '../src/client/chunk-loader.ts'
|
||||
|
||||
interface FakeModuleSystem {
|
||||
import: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function installModuleSystem(): FakeModuleSystem {
|
||||
const fake: FakeModuleSystem = {
|
||||
import: vi.fn(async (specifier: string) => ({ seed: specifier })),
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).__DSH_MODULES__ = fake
|
||||
return fake
|
||||
}
|
||||
|
||||
function removeModuleSystem(): void {
|
||||
delete (globalThis as Record<string, unknown>).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The global registry the chunk scripts populate (mirror of chunk-loader). */
|
||||
function registry(): Record<string, unknown> {
|
||||
return (globalThis as { __dshChunks__?: Record<string, unknown> }).__dshChunks__ ?? {}
|
||||
}
|
||||
|
||||
/** Simulate a chunk script executing: it assigns its factory to the registry. */
|
||||
function simulateScript(name: string, factory: (require: (spec: string) => unknown) => ChunkExports): void {
|
||||
const g = globalThis as { __dshChunks__?: Record<string, unknown> }
|
||||
g.__dshChunks__ = g.__dshChunks__ ?? {}
|
||||
g.__dshChunks__[name] = factory
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
removeModuleSystem()
|
||||
delete (globalThis as Record<string, unknown>).__dshChunks__
|
||||
resetChunks()
|
||||
setChunkScriptLoaderForTests(null)
|
||||
})
|
||||
|
||||
describe('test-registry path (vitest / jsdom-less environments)', () => {
|
||||
it('returns the registered chunk exports, loading once even for concurrent callers', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('docx', async () => {
|
||||
calls += 1
|
||||
return { DocxView: 'docx-view' }
|
||||
})
|
||||
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
|
||||
expect(a).toEqual({ DocxView: 'docx-view' })
|
||||
expect(b).toBe(a)
|
||||
expect(c).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
// Memoized after resolution too.
|
||||
expect(await loadChunk('docx')).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('a failed load clears the cache so the next call retries', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('pptx', async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production path (script injection + global registry + externals require)', () => {
|
||||
it('injects the chunk script, then materializes the factory with externals from the module table', async () => {
|
||||
const modules = installModuleSystem()
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
loaded.push(src)
|
||||
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
|
||||
})
|
||||
const exports = await loadChunk('xlsx')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
|
||||
expect(exports).toEqual({ XlsxView: 'view:[object Object]' })
|
||||
// Externals resolved through the module system's seed branch, once.
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
// Memoized: no second script injection, no second externals resolution.
|
||||
await loadChunk('xlsx')
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
|
||||
it('resolves the externals table once across chunks', async () => {
|
||||
const modules = installModuleSystem()
|
||||
const seen: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
seen.push(src)
|
||||
simulateScript(src.endsWith('editor.js') ? 'editor' : 'terminal', () => ({}))
|
||||
})
|
||||
await loadChunk('terminal')
|
||||
await loadChunk('editor')
|
||||
expect(seen).toEqual(['/sidebar/bundle/terminal.js', '/sidebar/bundle/editor.js'])
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
|
||||
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
|
||||
})
|
||||
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
|
||||
})
|
||||
|
||||
it('a script load failure rejects without materializing', async () => {
|
||||
const modules = installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => { throw new Error('script 404') })
|
||||
await expect(loadChunk('terminal')).rejects.toThrow('script 404')
|
||||
expect(modules.import).not.toHaveBeenCalled()
|
||||
// Cache cleared: the retry re-attempts the script load.
|
||||
setChunkScriptLoaderForTests(async () => { simulateScript('terminal', () => ({ TerminalView: 'ok' })) })
|
||||
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'ok' })
|
||||
})
|
||||
|
||||
it('a script that ran but registered no factory fails with a clear error', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => { /* executes but assigns nothing */ })
|
||||
await expect(loadChunk('editor')).rejects.toThrow('did not register its factory')
|
||||
})
|
||||
|
||||
it('a materialization failure clears the cache; the retry re-executes cleanly (idempotent slot assignment)', async () => {
|
||||
const modules = installModuleSystem()
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('pptx', () => {
|
||||
if (calls === 1) throw new Error('materialize boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
})
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
|
||||
// Retry: script re-injected, slot overwritten, materialization succeeds.
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails loudly when no module system is installed (before touching the network)', async () => {
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => { loaded.push(src) })
|
||||
await expect(loadChunk('docx')).rejects.toThrow('client module system unavailable')
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
|
||||
it('resetChunks drops the cache and the externals memo (HMR re-activation)', async () => {
|
||||
const modules = installModuleSystem()
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
|
||||
})
|
||||
await loadChunk('editor')
|
||||
expect(calls).toBe(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
resetChunks()
|
||||
// Cache dropped: the next open re-fetches and re-executes; the externals
|
||||
// memo is rebuilt (fresh module-table resolution).
|
||||
await loadChunk('editor')
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
|
||||
})
|
||||
|
||||
it('resetChunks is a safe no-op without a module system', () => {
|
||||
resetChunks()
|
||||
expect(() => resetChunks()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('externals contract', () => {
|
||||
it('the loader resolves exactly the platform externals the chunk builds keep external', () => {
|
||||
expect(CHUNK_EXTERNALS).toEqual([
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the editor-load planning: the pure strategy dispatch the
|
||||
* editor host runs (planFirstMatch / planFsReadOutcome / decodeHead).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts'
|
||||
import type { FileViewerDescriptor } from '../src/client/service.ts'
|
||||
|
||||
const viewer = (over: Partial<FileViewerDescriptor>): FileViewerDescriptor => ({
|
||||
id: 'v',
|
||||
exts: [],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: () => null,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('planFirstMatch', () => {
|
||||
it('no viewer → binary (download UI)', () => {
|
||||
expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary-download strategy → binary', () => {
|
||||
const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('mediaUrl / none → render with the media URL', () => {
|
||||
const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' })
|
||||
expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' })
|
||||
expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render')
|
||||
})
|
||||
|
||||
it('custom → customLoad (the viewer loads its own bytes)', () => {
|
||||
const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v })
|
||||
})
|
||||
|
||||
it('fsRead → fetchFsRead', () => {
|
||||
const v = viewer({ id: 'code', fetchStrategy: 'fsRead' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v })
|
||||
})
|
||||
})
|
||||
|
||||
describe('planFsReadOutcome', () => {
|
||||
const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
// The builtin binary-download NUL probe shape.
|
||||
if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' })
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('text result renders with content, same viewer', () => {
|
||||
const v = viewer({ id: 'code' })
|
||||
const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m')
|
||||
expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true })
|
||||
})
|
||||
|
||||
it('binary without head bytes → binary', () => {
|
||||
expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary whose NUL head re-matches nothing renderable → binary', () => {
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes
|
||||
rematch,
|
||||
() => '/m',
|
||||
)
|
||||
// binary-download claims it — still the download UI.
|
||||
expect(action).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => {
|
||||
const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => sniffer,
|
||||
() => '/m',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'customLoad', viewer: sniffer })
|
||||
})
|
||||
|
||||
it('binary re-matched to a mediaUrl viewer → render with the media URL', () => {
|
||||
const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => media,
|
||||
() => '/m?x=1',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeHead', () => {
|
||||
it('decodes base64 to the exact bytes', () => {
|
||||
const head = decodeHead('AP//AA==')
|
||||
expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* /sidebar/html route vocabulary tests: the path-encoded URL scheme must
|
||||
* round-trip absolute paths (POSIX and Windows), survive special characters,
|
||||
* and refuse malformed input — and crucially, a relative asset reference
|
||||
* resolved against an encoded document URL stays inside the same route with
|
||||
* the session scope intact (the WHY of the path-encoding design).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
|
||||
|
||||
describe('encodeHtmlUrl', () => {
|
||||
it('encodes a POSIX absolute path into route segments', () => {
|
||||
expect(encodeHtmlUrl('sess-1', '/Users/me/proj/index.html'))
|
||||
.toBe('/sidebar/html/sess-1/Users/me/proj/index.html')
|
||||
})
|
||||
|
||||
it('encodes a Windows absolute path (drive colon percent-encoded)', () => {
|
||||
expect(encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html'))
|
||||
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
|
||||
})
|
||||
|
||||
it('percent-encodes special characters in segments', () => {
|
||||
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
|
||||
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
|
||||
})
|
||||
|
||||
it('ignores leading/trailing slashes (files only)', () => {
|
||||
expect(encodeHtmlUrl('s', '/a//b/x.html')).toBe('/sidebar/html/s/a/b/x.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeHtmlUrl', () => {
|
||||
it('decodes a POSIX round-trip', () => {
|
||||
const url = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/Users/me/proj/index.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a Windows round-trip', () => {
|
||||
const url = encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/C:/Users/me/a.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes special characters round-trip', () => {
|
||||
const url = encodeHtmlUrl('s-1', '/a b/中文/100%.html')
|
||||
const result = decodeHtmlUrl(url)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.ref.path).toBe('/a b/中文/100%.html')
|
||||
})
|
||||
|
||||
it('refuses a wrong prefix (404)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/api/fs.read')).toEqual({
|
||||
ok: false, status: 404, message: 'not an html route',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an empty or double-slash path (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/').ok).toBe(false)
|
||||
expect(decodeHtmlUrl('/sidebar/html//s/a.html').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses malformed percent encoding (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/s/%E0%A4%A').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a missing sessionId or file path (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html//a.html')).toEqual({
|
||||
ok: false, status: 400, message: 'sessionId and file path are required',
|
||||
})
|
||||
expect(decodeHtmlUrl('/sidebar/html/s/')).toEqual({
|
||||
ok: false, status: 400, message: 'sessionId and file path are required',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps encoded traversal segments for the isWithin fence to bound', () => {
|
||||
// The decoder is not a security boundary by itself: an encoded `..`
|
||||
// decodes to `..` and the HOST refuses it via requireAbsolute +
|
||||
// isWithin(cwd) (the decoded path resolves outside the cwd).
|
||||
const result = decodeHtmlUrl('/sidebar/html/s/Users/me/../../etc/passwd')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.ref.path).toBe('/Users/me/../../etc/passwd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative asset resolution stays in-route', () => {
|
||||
it('a relative reference against an encoded document URL keeps the session scope', () => {
|
||||
// WHATWG URL resolution drops the QUERY of a path-relative reference,
|
||||
// which is why the route is path-encoded: resolving ./style.css against
|
||||
// the document URL must land back on the same route with the same
|
||||
// session prefix.
|
||||
const doc = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
|
||||
const asset = new URL('./style.css', `http://h${doc}`).pathname
|
||||
expect(asset).toBe('/sidebar/html/sess-1/Users/me/proj/style.css')
|
||||
expect(decodeHtmlUrl(asset)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/Users/me/proj/style.css' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deeper and parent-relative references resolve inside the route', () => {
|
||||
const doc = `http://h${encodeHtmlUrl('s', '/a/b/index.html')}`
|
||||
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
|
||||
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* IME-composition guard tests.
|
||||
*
|
||||
* The guard registers document CAPTURE-phase keydown/keyup listeners that
|
||||
* stopPropagation() while an input method is composing (isComposing or the
|
||||
* legacy keyCode 229). These tests pin:
|
||||
*
|
||||
* 1. the pure decision (`isImeComposition`);
|
||||
* 2. the native-listener path — a bubble listener on `document` and a
|
||||
* target-phase listener on the input must NOT see composition keys, but
|
||||
* must see every other key;
|
||||
* 3. the React-synthetic path — a React `onKeyDown` handler (delegated at
|
||||
* the root container) must not fire for composition keys, proving the
|
||||
* document-capture listener wins the ordering race against React's
|
||||
* delegation (the mechanism that inlined third-party UI, e.g. Univer's
|
||||
* InputNumber, uses to hijack ArrowUp/ArrowDown);
|
||||
* 4. the disposer restores normal flow (HMR-safe).
|
||||
*
|
||||
* Events are dispatched on a deep element (an `<input>` in `document.body`)
|
||||
* exactly like the browser does, so capture-phase blocking behaves like in
|
||||
* production — dispatching on `document` itself would run all listeners in
|
||||
* the target phase where stopPropagation does not stop same-node listeners.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { isImeComposition, registerImeGuard } from '../src/client/ime-guard.ts'
|
||||
|
||||
/** Build a KeyboardEvent the way jsdom allows: keyCode/isComposing via defineProperty. */
|
||||
function keyEvent(
|
||||
type: 'keydown' | 'keyup',
|
||||
init: { key: string; isComposing?: boolean; keyCode?: number; bubbles?: boolean },
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent(type, { key: init.key, bubbles: init.bubbles ?? true, cancelable: true })
|
||||
if (init.isComposing !== undefined) {
|
||||
Object.defineProperty(event, 'isComposing', { value: init.isComposing })
|
||||
}
|
||||
if (init.keyCode !== undefined) {
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
describe('isImeComposition', () => {
|
||||
it('treats isComposing as composition', () => {
|
||||
expect(isImeComposition({ isComposing: true, keyCode: 0 })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats keyCode 229 as composition (legacy engines without isComposing)', () => {
|
||||
expect(isImeComposition({ isComposing: false, keyCode: 229 })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats both signals together as composition', () => {
|
||||
expect(isImeComposition({ isComposing: true, keyCode: 229 })).toBe(true)
|
||||
})
|
||||
|
||||
it('lets ordinary keys through', () => {
|
||||
expect(isImeComposition({ isComposing: false, keyCode: 40 })).toBe(false)
|
||||
expect(isImeComposition({ isComposing: false, keyCode: 0 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerImeGuard — native listeners', () => {
|
||||
let input: HTMLInputElement
|
||||
let dispose: (() => void) | undefined
|
||||
const seen: string[] = []
|
||||
|
||||
const onDocumentBubble = (event: Event): void => {
|
||||
seen.push(`document:${event.type}`)
|
||||
}
|
||||
const onInputTarget = (event: Event): void => {
|
||||
seen.push(`input:${event.type}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
seen.length = 0
|
||||
document.addEventListener('keydown', onDocumentBubble)
|
||||
document.addEventListener('keyup', onDocumentBubble)
|
||||
input.addEventListener('keydown', onInputTarget)
|
||||
input.addEventListener('keyup', onInputTarget)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
document.removeEventListener('keydown', onDocumentBubble)
|
||||
document.removeEventListener('keyup', onDocumentBubble)
|
||||
input.removeEventListener('keydown', onInputTarget)
|
||||
input.removeEventListener('keyup', onInputTarget)
|
||||
input.remove()
|
||||
})
|
||||
|
||||
it('lets ordinary keys reach document bubble and input target listeners', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown' }))
|
||||
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown' }))
|
||||
expect(seen).toEqual(['input:keydown', 'document:keydown', 'input:keyup', 'document:keyup'])
|
||||
})
|
||||
|
||||
it('blocks composition keys (isComposing) from every downstream listener', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks composition keys signalled by keyCode 229 only', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', keyCode: 229 }))
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('still blocks after the composition signal is gone (guard is per-event)', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
|
||||
expect(seen).toEqual(['input:keydown', 'document:keydown'])
|
||||
})
|
||||
|
||||
it('disposer restores normal flow', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual([])
|
||||
dispose()
|
||||
dispose = undefined
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual(['input:keydown', 'document:keydown'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerImeGuard — React synthetic path', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let input: HTMLInputElement
|
||||
let dispose: (() => void) | undefined
|
||||
const seen: string[] = []
|
||||
|
||||
const onReactKeyDown = (): void => {
|
||||
seen.push('react:keydown')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement('input', { 'data-testid': 'ime-target', onKeyDown: onReactKeyDown }))
|
||||
})
|
||||
input = container.querySelector('input') as HTMLInputElement
|
||||
seen.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('baseline: React onKeyDown receives composition keys when the guard is absent', () => {
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual(['react:keydown'])
|
||||
})
|
||||
|
||||
it('guard blocks React synthetic onKeyDown during composition (capture beats delegation)', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual([])
|
||||
// Ordinary keys still flow through React after the guard is in place.
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
|
||||
expect(seen).toEqual(['react:keydown'])
|
||||
})
|
||||
|
||||
it('disposer restores the React synthetic path', () => {
|
||||
dispose = registerImeGuard()
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual([])
|
||||
dispose()
|
||||
dispose = undefined
|
||||
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
||||
expect(seen).toEqual(['react:keydown'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Lazy wrapper tests (src/client/lazy-chunk.tsx): the wrapper that mounts
|
||||
* chunk-resident components from the built-in descriptors. Pins the two
|
||||
* contracts that matter for the descriptor API:
|
||||
* - the wrapper is a plain render-prop function — `component(props)` can be
|
||||
* called directly (Sidebar's style) without a chunk registered and
|
||||
* without throwing; hooks live in the inner component only,
|
||||
* - loading → placeholder, failure → error + retry that recovers, success →
|
||||
* the chunk component rendered.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createElement, type ComponentType, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { builtinTabs } from '../src/client/builtins/tabs.tsx'
|
||||
import { builtinViewers } from '../src/client/builtins/viewers.tsx'
|
||||
import { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts'
|
||||
import { lazyChunkComponent } from '../src/client/lazy-chunk.tsx'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import type { FileViewerProps, TabComponentProps } from '../src/client/service.ts'
|
||||
import css from '../src/client/sidebar.module.css'
|
||||
|
||||
/** Render `node` into a detached body container under React's act(). */
|
||||
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
// act() flushes the (concurrent) initial commit — the placeholder must be
|
||||
// in the DOM before the async load flushes below.
|
||||
act(() => { root.render(node) })
|
||||
const unmount = (): void => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
return { container, unmount }
|
||||
}
|
||||
|
||||
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
|
||||
|
||||
beforeEach(() => {
|
||||
resetChunks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Defensive: drop any containers left by failed assertions.
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('lazyChunkComponent', () => {
|
||||
it('renders the loading placeholder first, then the chunk component', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('editor', async () => {
|
||||
calls += 1
|
||||
return { TextEditor: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'x' }))
|
||||
// Initial paint: the loading placeholder (no chunk loaded yet).
|
||||
expect(container.querySelector(`.${css.editorPlaceholder}`)).not.toBeNull()
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
expect(container.textContent).toContain('loaded')
|
||||
expect(calls).toBe(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows the failure reason with a retry that recovers', async () => {
|
||||
let fail = true
|
||||
registerChunkForTests('docx', async () => {
|
||||
if (fail) throw new Error('boom')
|
||||
return { DocxView: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, {}))
|
||||
await act(async () => {})
|
||||
expect(container.textContent).toContain('boom')
|
||||
// The failed load cleared the loader cache; retry now succeeds.
|
||||
fail = false
|
||||
const button = container.querySelector('button')
|
||||
expect(button).not.toBeNull()
|
||||
await act(async () => { button!.click() })
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('props flow through to the chunk component', async () => {
|
||||
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
|
||||
registerChunkForTests('pptx', async () => ({ PptxView: Recorder }))
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('pptx', (mod) => mod.PptxView as ComponentType<{ label: string }> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in descriptor contract (render-prop functions)', () => {
|
||||
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
|
||||
const viewers = builtinViewers()
|
||||
for (const id of ['docx', 'xlsx', 'pptx', 'markdown', 'html', 'code']) {
|
||||
const descriptor = viewers.find(viewer => viewer.id === id)
|
||||
expect(descriptor, id).toBeDefined()
|
||||
// No chunk registered: calling must not throw — it returns the lazy
|
||||
// element (the loading placeholder renders once mounted).
|
||||
expect(() => descriptor!.component({} as FileViewerProps), id).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('the terminal tab component keeps the same contract', () => {
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')
|
||||
expect(terminal).toBeDefined()
|
||||
// The descriptor reads tab.id (the tabId mapping); a real tab is part of
|
||||
// the contract — Sidebar always provides one.
|
||||
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
|
||||
expect(() => terminal!.component(props)).not.toThrow()
|
||||
})
|
||||
|
||||
it('a built-in viewer mounted without a chunk available degrades to the error + retry affordance (no crash)', async () => {
|
||||
// No test chunk registered and jsdom has no client module system: the
|
||||
// load fails and the wrapper must degrade gracefully, never throw.
|
||||
const viewers = builtinViewers()
|
||||
const code = viewers.find(viewer => viewer.id === 'code')!
|
||||
const { container, unmount } = mount(createElement(code.component, {} as FileViewerProps))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector(`.${css.editorError}`)).not.toBeNull()
|
||||
expect(container.querySelector('button')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('a registered chunk makes the built-in viewer render through the real descriptor path', async () => {
|
||||
registerChunkForTests('editor', async () => ({ TextEditor: Marker }))
|
||||
const viewers = builtinViewers()
|
||||
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
|
||||
// EditorHost renders viewer components via createElement(component, props).
|
||||
const { container, unmount } = mount(createElement(markdown.component, {
|
||||
ctx: {},
|
||||
store: undefined,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a.md',
|
||||
title: 'a.md',
|
||||
viewerId: 'markdown',
|
||||
} as unknown as FileViewerProps))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('the terminal descriptor maps tab.id → tabId (TerminalView props are not TabComponentProps)', async () => {
|
||||
let received: unknown
|
||||
registerChunkForTests('terminal', async () => ({
|
||||
TerminalView: ((props: { tabId: string }) => {
|
||||
received = props.tabId
|
||||
return createElement('div', { 'data-testid': 'terminal-tabid' }, props.tabId)
|
||||
}) as unknown as ComponentType<Record<string, never>>,
|
||||
}))
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')!
|
||||
const props = {
|
||||
ctx: {},
|
||||
store: undefined,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
tab: { id: 'terminal:2', type: 'terminal', title: '终端 2' },
|
||||
visible: true,
|
||||
} as unknown as TabComponentProps
|
||||
const { container, unmount } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="terminal-tabid"]')?.textContent).toBe('terminal:2')
|
||||
expect(received).toBe('terminal:2')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('the loader cache survives across descriptor mounts (chunk fetched once)', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('terminal', async () => {
|
||||
calls += 1
|
||||
return { TerminalView: Marker }
|
||||
})
|
||||
const tabs = builtinTabs({} as Context)
|
||||
const terminal = tabs.find(tab => tab.id === 'terminal')!
|
||||
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
|
||||
const { container: first, unmount: unmountFirst } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmountFirst()
|
||||
const { container: second, unmount: unmountSecond } = mount(createElement(terminal.component, props))
|
||||
await act(async () => {})
|
||||
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
||||
unmountSecond()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External-link interception policy tests: the pure decision (which http(s)
|
||||
* external links are taken over into the sidebar browser) and the click
|
||||
* shape (plain left clicks only — modified clicks always bypass).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
|
||||
|
||||
const SELF = 'http://127.0.0.1:3080'
|
||||
|
||||
describe('shouldInterceptLink', () => {
|
||||
it('takes over http(s) external links', () => {
|
||||
expect(shouldInterceptLink('https://example.com/page?a=1', SELF)).toBe('https://example.com/page?a=1')
|
||||
expect(shouldInterceptLink('http://example.com/', SELF)).toBe('http://example.com/')
|
||||
})
|
||||
|
||||
it('ignores non-http(s) links (mailto:, javascript:, file:)', () => {
|
||||
expect(shouldInterceptLink('mailto:a@b.c', SELF)).toBeNull()
|
||||
expect(shouldInterceptLink('javascript:void(0)', SELF)).toBeNull()
|
||||
expect(shouldInterceptLink('file:///tmp/x.html', SELF)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unparsable hrefs', () => {
|
||||
expect(shouldInterceptLink('not a url', SELF)).toBeNull()
|
||||
})
|
||||
|
||||
it('never takes over same-origin (GUI-internal) links', () => {
|
||||
expect(shouldInterceptLink('http://127.0.0.1:3080/settings', SELF)).toBeNull()
|
||||
expect(shouldInterceptLink('http://127.0.0.1:3080/chat/session-x', SELF)).toBeNull()
|
||||
// A different port of the same host is external.
|
||||
expect(shouldInterceptLink('http://127.0.0.1:9999/', SELF)).toBe('http://127.0.0.1:9999/')
|
||||
})
|
||||
|
||||
it("takes over LAN hosts (the browser's own blocklist, not the link policy)", () => {
|
||||
// The link takeover funnels into the sidebar browser, which then applies
|
||||
// its own loopback/self address-bar policy on render — the interception
|
||||
// itself must not silently drop links the browser will surface as blocked.
|
||||
expect(shouldInterceptLink('http://192.168.1.1/', SELF)).toBe('http://192.168.1.1/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPlainLeftClick', () => {
|
||||
const plain = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false }
|
||||
|
||||
it('accepts a plain left click', () => {
|
||||
expect(isPlainLeftClick(plain)).toBe(true)
|
||||
})
|
||||
|
||||
it('bypasses modified clicks (Ctrl/Cmd/Shift/Alt) and non-left buttons', () => {
|
||||
expect(isPlainLeftClick({ ...plain, metaKey: true })).toBe(false)
|
||||
expect(isPlainLeftClick({ ...plain, ctrlKey: true })).toBe(false)
|
||||
expect(isPlainLeftClick({ ...plain, shiftKey: true })).toBe(false)
|
||||
expect(isPlainLeftClick({ ...plain, altKey: true })).toBe(false)
|
||||
expect(isPlainLeftClick({ ...plain, button: 1 })).toBe(false)
|
||||
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,17 @@ function bundleId(file: string): string {
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNK_FILES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
const match = /globalThis\.__dshChunks__\["([a-z0-9-]+)"\]/.exec(source)
|
||||
if (match === null) throw new Error(`${file} assigns no globalThis.__dshChunks__ slot — run \`pnpm build\` first`)
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
it('id is a valid two-segment registry id (native form)', () => {
|
||||
expect(manifest.id).toMatch(ID_PATTERN)
|
||||
@@ -86,13 +97,20 @@ describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
expect(registryId).not.toBe(pkg.name)
|
||||
})
|
||||
|
||||
it('the lazy chunk bundles exist and assign their global registry slots (served by /sidebar/bundle)', () => {
|
||||
for (const file of CHUNK_FILES) {
|
||||
expect(existsSync(resolve(ROOT, file)), file).toBe(true)
|
||||
expect(chunkSlot(file), file).toBe(file.slice('lib/client-'.length, -'.js'.length))
|
||||
}
|
||||
})
|
||||
|
||||
it('client bundles require only frozen module-table entries', () => {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!]) {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
// Exclude method calls such as `freeModule.require("util")`; only the
|
||||
// loader factory's lexical require() is constrained by the module table.
|
||||
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id)), file).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Orphaned-tab tests: a persisted tab whose type is not registered must
|
||||
* survive sanitize (title/path kept for recovery) and render the graceful
|
||||
* placeholder instead of crashing — the degradation path of the open
|
||||
* tab-type set (a contributing plugin not installed / not yet loaded).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { OrphanedTab } from '../src/client/OrphanedTab.tsx'
|
||||
import { sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
/** Collect every tab type in a sanitized tree (leaf/split recursive). */
|
||||
function typesOf(node: unknown): string[] {
|
||||
if (node === null || typeof node !== 'object') return []
|
||||
const record = node as { kind?: unknown; tabs?: unknown; children?: unknown }
|
||||
if (record.kind === 'leaf' && Array.isArray(record.tabs)) {
|
||||
return (record.tabs as Array<{ type: string }>).map(tab => tab.type)
|
||||
}
|
||||
if (Array.isArray(record.children)) return record.children.flatMap(typesOf)
|
||||
return []
|
||||
}
|
||||
|
||||
describe('sanitize keeps unregistered tab types', () => {
|
||||
it('preserves a persisted tab of an unknown type with its title/path', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [
|
||||
{ id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' },
|
||||
{ id: 'tab:2', type: 'explorer', title: 'Explorer' },
|
||||
],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
|
||||
// The kept tab keeps its payload so a later registration can reuse it.
|
||||
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
|
||||
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
|
||||
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
|
||||
})
|
||||
|
||||
it('an unknown type alone in a pane still sanitizes cleanly', () => {
|
||||
const raw = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: null,
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrphanedTab placeholder', () => {
|
||||
const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' }
|
||||
|
||||
it('renders the unregistered type and keeps the title', () => {
|
||||
const html = renderToString((
|
||||
<OrphanedTab
|
||||
ctx={undefined as never}
|
||||
store={undefined as never}
|
||||
scope={{ sessionId: 's1', cwd: '/p' }}
|
||||
tab={tab}
|
||||
visible={false}
|
||||
/>
|
||||
))
|
||||
expect(html).toContain('my-plugin:db')
|
||||
expect(html).toContain('Database')
|
||||
expect(html).not.toContain('undefined')
|
||||
})
|
||||
})
|
||||
@@ -45,15 +45,20 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A new background task auto-opens the Tasks page too.
|
||||
expect(resolved.autoOpenTasks).toBe(true)
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Sandbox-contract tests for the two built-in web surfaces (HTML preview
|
||||
* iframe and the browser tab iframe). The iframe sandbox — opaque origin,
|
||||
* no allow-same-origin, no top-navigation — is the PRIMARY security
|
||||
* boundary of both features; these tests pin the exact attribute so a
|
||||
* refactor cannot silently widen it. The side card settings can drop the
|
||||
* sandbox per-feature (warned); those paths render the warning bar and no
|
||||
* sandbox attribute.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
|
||||
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX } from '../src/client/BrowserView.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
const CTX = {} as Context
|
||||
|
||||
// The copy assertions below pin the zh strings: force the zh locale (the
|
||||
// test environment's navigator may be the real Node one with an en locale).
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
})
|
||||
|
||||
function viewerProps(store: ReturnType<typeof createSidebarStore>, overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a/index.html',
|
||||
title: 'index.html',
|
||||
viewerId: 'html',
|
||||
content: '<h1>hi</h1>',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('HTML preview iframe sandbox', () => {
|
||||
it('renders the preview iframe with the exact sandbox tokens and no same-origin / top-navigation', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
// The sandbox tokens are exactly the exported constant...
|
||||
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
||||
// ...which must never contain the dangerous tokens.
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
// Cross-origin framing by construction: route-src (never srcdoc).
|
||||
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
||||
expect(iframe).not.toContain('srcdoc=')
|
||||
// Referrer + permissions policy stay locked even when sandboxed.
|
||||
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('renders the live sandbox status row (green on + temporary unlock action)', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
// Sandbox ON: the green status + the one-tap temporary unlock button.
|
||||
expect(html).toContain('沙箱模式:已启用')
|
||||
expect(html).toContain('临时解锁(不安全)')
|
||||
// No restore action while the sandbox is on.
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
|
||||
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), htmlViewerNoSandbox: true })
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
// The red persistent warning copy is rendered; the temporary-unlock
|
||||
// action is NOT offered (re-enabling is the settings page's job).
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
|
||||
it('starts unsandboxed (red, restorable) when the default-unsafe pref is on', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), htmlViewerDefaultUnsafe: true })
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
// The red warning + the one-tap restore (this is the LOCAL state).
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).toContain('恢复沙箱')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
})
|
||||
|
||||
it('markdown preview keeps rendering markdown, not an iframe', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store, {
|
||||
viewerId: 'markdown',
|
||||
path: '/p/readme.md',
|
||||
content: '# hi',
|
||||
})))
|
||||
expect(html).not.toContain('<iframe')
|
||||
expect(html).not.toContain('/sidebar/html/')
|
||||
// The markdown is rendered into markup, not framed.
|
||||
expect(html).toContain('<h1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser tab iframe sandbox', () => {
|
||||
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
tab: { id: 'browser:1', type: 'browser', title: 'Browser', ...(path !== undefined ? { path } : {}) },
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
it('renders the start page before any navigation (no iframe, no auto-load)', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store)))
|
||||
expect(html).not.toContain('<iframe')
|
||||
expect(html).toContain('输入网址开始浏览')
|
||||
})
|
||||
|
||||
it('sandboxes the iframe without same-origin / top-navigation', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).toContain(`sandbox="${BROWSER_IFRAME_SANDBOX}"`)
|
||||
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
expect(iframe).toContain('src="https://example.com/"')
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('renders the live sandbox status row with the temporary unlock action', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
expect(html).toContain('沙箱模式:已启用')
|
||||
expect(html).toContain('临时解锁(不安全)')
|
||||
})
|
||||
|
||||
it('offers the open-in-browser action once a URL is loaded (disabled before navigation)', () => {
|
||||
const store = createSidebarStore()
|
||||
// No URL yet: the external-open action is disabled.
|
||||
const start = renderToString(createElement(BrowserView, tabProps(store)))
|
||||
expect(start).toContain('aria-label="在浏览器中打开"')
|
||||
expect(start).toContain('title="在浏览器中打开" disabled=""')
|
||||
// With a URL: enabled.
|
||||
const loaded = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
expect(loaded).toContain('aria-label="在浏览器中打开"')
|
||||
expect(loaded).not.toContain('title="在浏览器中打开" disabled=""')
|
||||
})
|
||||
|
||||
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), browserNoSandbox: true })
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser embed-refusal panel', () => {
|
||||
it('explains the refusal with the host, the reason, and both actions', () => {
|
||||
const html = renderToString(createElement(BrowserEmbedBlocked, {
|
||||
url: 'https://arxiv.org/abs/2401.10001',
|
||||
onOpenInBrowser: () => {},
|
||||
onLoadAnyway: () => {},
|
||||
}))
|
||||
expect(html).toContain('arxiv.org 拒绝了嵌入请求')
|
||||
expect(html).toContain('X-Frame-Options / frame-ancestors')
|
||||
expect(html).toContain('在浏览器中打开')
|
||||
expect(html).toContain('仍然加载')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
|
||||
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
}
|
||||
}
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
const dispose = service.registerTab({
|
||||
id: 'test:tab',
|
||||
title: 'Test',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getTabs()).toHaveLength(1)
|
||||
expect(service.getTab('test:tab')?.id).toBe('test:tab')
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getTab('test:tab')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registerTab throws on duplicate id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'dup', title: 'A', component: () => null })
|
||||
expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow()
|
||||
})
|
||||
|
||||
it('registerFileViewer adds and dispose removes', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
const dispose = service.registerFileViewer({
|
||||
id: 'csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getFileViewers()).toHaveLength(1)
|
||||
dispose()
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('subscribe fires on register and dispose', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
let calls = 0
|
||||
const unsub = service.subscribe(() => { calls++ })
|
||||
const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null })
|
||||
expect(calls).toBe(1)
|
||||
dispose()
|
||||
expect(calls).toBe(2)
|
||||
unsub()
|
||||
service.registerTab({ id: 'y', title: 'Y', component: () => null })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enable switches (declarative settings)', () => {
|
||||
/** A fresh store + service with one tab and one viewer registered. */
|
||||
const setup = () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
service.registerFileViewer({ id: 'image', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
it('an absent map key means enabled (default state)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
expect(service.isViewerEnabled('image')).toBe(true)
|
||||
// Unknown ids are enabled too (nothing registered → the check is open).
|
||||
expect(service.isTabEnabled('whatever')).toBe(true)
|
||||
})
|
||||
|
||||
it('only an explicit false disables a tab type', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(false)
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: true } })
|
||||
expect(service.isTabEnabled('explorer')).toBe(true)
|
||||
})
|
||||
|
||||
it('openTab refuses a disabled tab type (no tab lands, no createTab)', () => {
|
||||
const { store, service } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
|
||||
const { store, service } = setup()
|
||||
service.registerFileViewer({ id: 'code', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
// image claims .png; disabling it lets the catch-all take over.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
|
||||
// Disabling the catch-all too → no viewer at all.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false, code: false } })
|
||||
expect(service.matchFileViewer('photo.png')).toBeUndefined()
|
||||
// Re-enabling restores the image viewer.
|
||||
store.setPrefs({ ...store.getPrefs(), viewersEnabled: {} })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFileViewer', () => {
|
||||
it('matches by extension', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('photo.JPG')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('higher priority wins on extension conflict', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('advanced')
|
||||
})
|
||||
|
||||
it('catch-all (exts: []) matches anything at lowest priority', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
|
||||
})
|
||||
|
||||
it('detect claims files the viewer would otherwise miss, at its priority', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// by-magic does not match 'bin' by extension; only its detect (PNG magic)
|
||||
// can claim it — and only when head bytes are available.
|
||||
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 5, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'by-magic',
|
||||
exts: ['mag'],
|
||||
priority: 10,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// No head: by-magic's exts miss, by-ext claims .bin.
|
||||
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
|
||||
// Head with PNG magic: by-magic's detect fires at priority 10 before
|
||||
// by-ext (5) is consulted.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
|
||||
// Head without the magic: detect misses, by-ext claims .bin again.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x00, 0x50]))?.id).toBe('by-ext')
|
||||
})
|
||||
|
||||
it('priority decides first: a higher-priority exts match beats a lower-priority detect', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'by-magic',
|
||||
exts: ['mag'],
|
||||
priority: 5,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// Per-descriptor walk: by-ext (priority 10) claims .bin before by-magic's
|
||||
// detect (priority 5) is ever consulted — the design's priority-first rule.
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext')
|
||||
})
|
||||
|
||||
it('a catch-all with detect is sniff-only: it never blind-claims without head', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'magic-sniffer',
|
||||
exts: [],
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// No head: the sniff-only catch-all yields — the real png viewer wins.
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
// Head with the magic: the sniffer claims it (detect at priority 100).
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer')
|
||||
// Head without the magic: the sniffer yields again, img claims it.
|
||||
expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img')
|
||||
})
|
||||
|
||||
it('returns undefined when no viewer matches and no catch-all', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab dedupe', () => {
|
||||
it('dedupeKey focuses existing tab instead of duplicating', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'singleton',
|
||||
title: 'Singleton',
|
||||
dedupeKey: () => 'singleton',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('no dedupeKey opens a new tab for each distinct id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reopening with the same id focuses the existing tab (id safety net)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('createTab mints custom ids and patches state', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'counter',
|
||||
title: 'Counter',
|
||||
createTab: (state) => ({
|
||||
tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}),
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('counter:1')
|
||||
expect(tabs[1]!.id).toBe('counter:2')
|
||||
expect(state.nextTerminal).toBe(3)
|
||||
})
|
||||
|
||||
it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
it('a url seed lands the tab with its path pre-set (the sidebar-browser navigation seed)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'browser',
|
||||
title: () => 'Browser',
|
||||
createTab: (state) => ({
|
||||
tab: { id: `browser:${state.nextBrowser}`, type: 'browser', title: 'Browser' },
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'browser', url: 'https://example.com/x', title: 'example.com' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'browser')
|
||||
expect(tab?.id).toBe('browser:1')
|
||||
expect(tab?.path).toBe('https://example.com/x')
|
||||
expect(tab?.title).toBe('example.com')
|
||||
expect(state.nextBrowser).toBe(2)
|
||||
})
|
||||
|
||||
it('the descriptor title is the default when no title is given', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'plain' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
|
||||
expect(tab?.title).toBe('Plain')
|
||||
})
|
||||
|
||||
it('single: true dedupes like dedupeKey: () => id (sugar)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton' })
|
||||
service.openTab({ type: 'singleton', id: 'singleton:extra' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an explicit dedupeKey wins over single: true', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
single: true,
|
||||
dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs).toHaveLength(2)
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
const state2 = store.getSnapshot().state!
|
||||
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs2).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('available receives ctx, scope and the live state (superset signature)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: unknown[] = []
|
||||
service.registerTab({
|
||||
id: 'gated',
|
||||
title: 'Gated',
|
||||
available: (ctx, scope, state) => {
|
||||
seen.push([ctx, scope, state])
|
||||
return true
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true)
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'diff',
|
||||
title: 'Diff',
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false }
|
||||
const paneId = store.getSnapshot().state!.activePane!
|
||||
// The git view's placement path (split surgery) opens the diff tab...
|
||||
store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed }))
|
||||
// ...and a service open of the same change focuses the existing tab
|
||||
// (the descriptor's dedupeKey is the same per-change id rule).
|
||||
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab across the two panels', () => {
|
||||
it('openTab lands in the bottom tree when the active pane lives there', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'git', title: 'Git', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupeKey focuses an existing instance in the OTHER tree (single-instance across panels)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'singleton',
|
||||
title: 'Singleton',
|
||||
single: true,
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
// Open in the right tree first.
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
// Switch the active pane to the bottom tree and open again: the dedupe
|
||||
// scan covers both trees, so the existing instance is focused, not
|
||||
// duplicated in the bottom panel.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const total = allLeaves(state.splits).concat(allLeaves(state.bottomSplits))
|
||||
.flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(total).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closeTab by id closes a tab living in the bottom tree', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'git', title: 'Git', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const state = store.getSnapshot().state!
|
||||
const gitTab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
|
||||
service.closeTab(gitTab.id)
|
||||
const after = store.getSnapshot().state!
|
||||
expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab auto-expand for content opens', () => {
|
||||
/** The window stub is a plain object (see the file header), so the width is writable. */
|
||||
const setWidth = (width: number): void => {
|
||||
;(g.window as { innerWidth: number }).innerWidth = width
|
||||
}
|
||||
/** Collapse the right panel (the store defaults it open). */
|
||||
const collapseRightPanel = (store: ReturnType<typeof createSidebarStore>): void => {
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
}
|
||||
|
||||
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands the collapsed right panel for a path (file) open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('expands the collapsed right panel for a URL (browser) open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
|
||||
expect(store.getSnapshot().state!.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('a wide-viewport path open landing in the bottom tree expands the bottom panel instead', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
// The last-touched pane lives in the bottom tree and BOTH panels are
|
||||
// collapsed: the open must surface the bottom panel, not the right one.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id, panelOpen: false, bottomOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(state.panelOpen).toBe(false)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a collapsed panel for a type-only open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
|
||||
setWidth(390)
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(true)
|
||||
} finally {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands on a wide viewport even when the open focuses an existing tab (id dedupe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Side card settings section render tests: the section is DECLARATIVE —
|
||||
* every small card (icon, title, type id, extensions, on/off state) derives
|
||||
* from the sidebar service's tab/viewer registries instead of hardcoded
|
||||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||||
* the switch, the visual state IS the state (highlighted = enabled),
|
||||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||||
* Features that declare related settings carry a gear corner button whose
|
||||
* popup rows (native checkboxes) are tested through the extracted
|
||||
* FeatureSettingsRows component (the Modal portal renders only while open).
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'explorer',
|
||||
title: () => 'Explorer',
|
||||
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
|
||||
order: 10,
|
||||
component: () => null,
|
||||
})
|
||||
service.registerTab({
|
||||
id: 'subagent',
|
||||
title: () => 'Subagents',
|
||||
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
|
||||
order: 30,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto-open Subagents',
|
||||
desc: () => 'Expand on new subagent',
|
||||
}],
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
service.registerFileViewer({
|
||||
id: 'image',
|
||||
title: () => 'Image',
|
||||
icon: () => createElement('svg', { 'data-icon': 'image' }),
|
||||
exts: ['png', 'jpg'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: () => null,
|
||||
})
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
|
||||
return renderToString(createElement(
|
||||
SideCardSection,
|
||||
{ store, service } as unknown as SideCardSectionProps,
|
||||
))
|
||||
}
|
||||
|
||||
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
|
||||
function pressedCount(html: string, value: string): number {
|
||||
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
|
||||
}
|
||||
|
||||
describe('SideCardSection declarative inventory', () => {
|
||||
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="explorer"')
|
||||
expect(html).toContain('>Explorer<')
|
||||
// The type id is the card's desc (the declarative "type" surface).
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
|
||||
// image viewer are all enabled → 5 cards pressed, none pressed=false.
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(5)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('data-icon="image"')
|
||||
expect(html).toContain('>Image<')
|
||||
// The covered extensions are the card's desc.
|
||||
expect(html).toContain('png · jpg')
|
||||
})
|
||||
|
||||
it('renders the gear corner button on features that declare related settings', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
// Subagents declares a toggle → its card carries the settings gear
|
||||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||||
// none → no gear.
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('a disabled feature renders pressed=false', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// openByDefault + interceptOpenPath + the explorer stay pressed.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
|
||||
...SIDEBAR_PREFS_DEFAULTS,
|
||||
autoOpenSubagent: false,
|
||||
}
|
||||
const toggles = [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto-open Subagents',
|
||||
desc: () => 'Expand on new subagent',
|
||||
}]
|
||||
|
||||
it('renders one native checkbox row per declared toggle with its current value', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
}))
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
// The checkbox reflects the prefs value (false here → unchecked).
|
||||
expect(html).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
it('checks the row when the pref is on', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs: { ...prefs, autoOpenSubagent: true },
|
||||
onToggle: () => {},
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
+92
-3
@@ -3,7 +3,7 @@
|
||||
* exercises the real integrations — route registration, git against the
|
||||
* actual repository, and a real directory listing. Runs with `pnpm test`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -27,6 +27,8 @@ interface FakeContext {
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
/** Optional services (tasks/agents) are read lazily; absent → undefined. */
|
||||
get: (key: string) => undefined
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
@@ -56,9 +58,11 @@ describe('host plugin smoke', () => {
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html'])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
@@ -315,6 +319,8 @@ describe('session cwd resolution over the API route', () => {
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
// No tasks/agents services in the smoke context: the routes degrade.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -464,6 +470,8 @@ describe('side card settings routes', () => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -502,7 +510,22 @@ describe('side card settings routes', () => {
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false },
|
||||
value: {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
},
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
@@ -533,8 +556,70 @@ describe('side card settings routes', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
/** Fake fetch responses shaped like what the route consumes. */
|
||||
const respond = (status: number, headers: Record<string, string>): Response =>
|
||||
({ status, url: 'https://site.example/', headers: new Headers(headers) }) as unknown as Response
|
||||
|
||||
it('reports X-Frame-Options and frame-ancestors from the target headers', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {
|
||||
'x-frame-options': 'SAMEORIGIN',
|
||||
'content-security-policy': "default-src 'self'; frame-ancestors 'none'",
|
||||
})))
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://arxiv.org/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({
|
||||
reachable: true,
|
||||
url: 'https://site.example/',
|
||||
status: 200,
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
frameAncestors: ["'none'"],
|
||||
})
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a 405 HEAD as GET', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(respond(405, {}))
|
||||
.mockResolvedValueOnce(respond(200, {}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.value).toMatchObject({ reachable: true, status: 200 })
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an unreachable target as reachable:false', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ENOTFOUND') }))
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ reachable: false })
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses non-http(s) and loopback URLs', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'http://127.0.0.1:8080/', 'http://localhost/']) {
|
||||
const result = await invoke(route, 'browser.probe', { url })
|
||||
expect(result.ok, url).toBe(false)
|
||||
expect(result.error?.code, url).toBe('bad-request')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('agent terminal tool gating', () => {
|
||||
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
@@ -571,6 +656,8 @@ describe('agent terminal tool gating', () => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
// Default off: no tools are registered even though the settings service is mounted.
|
||||
@@ -592,4 +679,6 @@ describe('agent terminal tool gating', () => {
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Subagent page tests for the background-task section: rows render from the
|
||||
* `tasksBySession` mirror, clicking a row peeks its output through
|
||||
* `tasks.output` with the OWNER session scope, the kill button needs a
|
||||
* two-click confirm, settled rows offer no kill, and the output panel never
|
||||
* polls while the page is hidden.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
function makeStore(initial: SidebarSessionList) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
set(next: SidebarSessionList) {
|
||||
snapshot = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Store = ReturnType<typeof makeStore>
|
||||
|
||||
/** The client context face SubagentView touches (history stub; everything else inert). */
|
||||
function makeCtx(store: Store): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: store,
|
||||
setSubagentCatalogOpen: () => {},
|
||||
openSubagent: () => {},
|
||||
open: () => {},
|
||||
refreshSubagents: async () => {},
|
||||
},
|
||||
connection: {
|
||||
api: {
|
||||
subagents: {
|
||||
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** Render `node` into a detached body container under React's act(). */
|
||||
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(node) })
|
||||
const unmount = (): void => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
return { container, unmount }
|
||||
}
|
||||
|
||||
const outputCalls: Array<{ sessionId: string; id: string }> = []
|
||||
const killCalls: Array<{ sessionId: string; id: string }> = []
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
}
|
||||
|
||||
function baseSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
root: { id: 'root', displayTitle: '主会话', running: true },
|
||||
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {},
|
||||
tasksBySession: {
|
||||
root: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
|
||||
],
|
||||
child: [
|
||||
{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
outputCalls.length = 0
|
||||
killCalls.length = 0
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
|
||||
if (method === 'tasks.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
// bash-9 stands for a task the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
|
||||
truncated: false,
|
||||
read: body.id !== 'bash-9',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'tasks.kill') {
|
||||
killCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('SubagentView background tasks', () => {
|
||||
it('renders the tree tasks with status, durations, and owner labels', () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const text = container.textContent ?? ''
|
||||
expect(text).toContain('后台命令')
|
||||
expect(text).toContain('2 个后台命令 · 1 运行中')
|
||||
// Both rows of the whole tree, owner-labeled (the tree spans two sessions).
|
||||
expect(text).toContain('sleep 300')
|
||||
expect(text).toContain('echo hi')
|
||||
expect(text).toContain('主会话')
|
||||
expect(text).toContain('子代理')
|
||||
// Only the running row offers a kill button.
|
||||
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('renders nothing task-related when the mirror is empty', () => {
|
||||
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} })
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).not.toContain('后台命令')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// The mirror empties (all tasks settled and dropped): the section must
|
||||
// vanish WITHOUT reordering hooks — a hook below the empty-state return
|
||||
// would crash React with "Rendered fewer hooks than expected" (the
|
||||
// minified #300 the sidebar boundary surfaces with a retry button).
|
||||
await act(async () => { store.set({ ...baseSnapshot(), tasksBySession: {} }) })
|
||||
expect(container.textContent).not.toContain('后台命令')
|
||||
// And returning tasks must work too, with the same hook order.
|
||||
await act(async () => { store.set(baseSnapshot()) })
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows the selected task output in the bottom dock, closeable', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// The peek request carries the OWNER session (the fence compares it).
|
||||
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
|
||||
expect(container.textContent).toContain('output-of-bash-1')
|
||||
// Exactly one dock region exists (never one per row).
|
||||
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
|
||||
// The selected row is marked, and the close button dismisses the dock.
|
||||
expect(row.getAttribute('aria-pressed')).toBe('true')
|
||||
const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement
|
||||
await act(async () => { close.click() })
|
||||
expect(container.textContent).not.toContain('output-of-bash-1')
|
||||
expect(container.querySelectorAll('[role="region"]')).toHaveLength(0)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('switches the single dock between selected rows', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { first.click() })
|
||||
expect(container.textContent).toContain('output-of-bash-1')
|
||||
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
|
||||
await act(async () => { second.click() })
|
||||
// One dock, now fed by the second task (its owner session scopes the replay).
|
||||
expect(outputCalls).toEqual([
|
||||
{ sessionId: 'root', id: 'bash-1' },
|
||||
{ sessionId: 'child', id: 'bash-2' },
|
||||
])
|
||||
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
|
||||
expect(container.textContent).not.toContain('output-of-bash-1')
|
||||
expect(container.textContent).toContain('output-of-bash-2')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('explains when the model has not read the task yet', async () => {
|
||||
const snapshot = baseSnapshot()
|
||||
snapshot.tasksBySession = {
|
||||
root: [
|
||||
...(snapshot.tasksBySession?.root ?? []),
|
||||
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
|
||||
],
|
||||
}
|
||||
const store = makeStore(snapshot)
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// read:false → the pane explains the output awaits the model's task_output
|
||||
// (never the model's cursor, so there is nothing to steal yet).
|
||||
expect(container.textContent).toContain('等待模型读取该任务的输出')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stays compact and functional with many tasks', async () => {
|
||||
const many = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `bash-${index + 10}`,
|
||||
kind: 'bash' as const,
|
||||
label: `bulk cmd ${index}`,
|
||||
status: index % 2 === 0 ? ('running' as const) : ('completed' as const),
|
||||
startedAt: 1_000 + index,
|
||||
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
|
||||
}))
|
||||
const store = makeStore({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: '主会话' } },
|
||||
subagentsByParent: {},
|
||||
tasksBySession: { root: many },
|
||||
})
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).toContain('60 个后台命令 · 30 运行中')
|
||||
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
|
||||
// Clicking a row anywhere in the long list still feeds the single dock.
|
||||
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }])
|
||||
expect(container.textContent).toContain('output-of-bash-69')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('kills a live task only after the two-click confirm', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
|
||||
await act(async () => { kill.click() })
|
||||
// First click only arms the confirm — no request leaves the page.
|
||||
expect(killCalls).toEqual([])
|
||||
const confirm = container.querySelector('button[aria-label="再次点击确认终止"]')
|
||||
expect(confirm).not.toBeNull()
|
||||
await act(async () => { (confirm as HTMLButtonElement).click() })
|
||||
expect(killCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not poll the output while the page is hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
// One peek for the initial load; the 2s poll never ran while inactive.
|
||||
expect(outputCalls).toHaveLength(1)
|
||||
unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-task section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeTasks,
|
||||
detectNewTask,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-tasks.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
taskDurationSeconds: '{seconds}秒',
|
||||
taskDurationMinutes: '{minutes}分{seconds}秒',
|
||||
taskDurationHours: '{hours}小时{minutes}分',
|
||||
}
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
let text = templates[key] ?? key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: `title-${id}`, ...over }
|
||||
}
|
||||
|
||||
function task(id: string, over: Partial<SidebarTaskView> = {}): SidebarTaskView {
|
||||
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
|
||||
}
|
||||
|
||||
describe('treeSessionIds', () => {
|
||||
it('includes the root and every subagent-origin session whose chain reaches it', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
|
||||
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
|
||||
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
|
||||
}
|
||||
const ids = treeSessionIds(byId, 'root')
|
||||
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
|
||||
})
|
||||
|
||||
it('fails soft on parent cycles and yields nothing without a root', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
a: summary('a', { origin: 'subagent', parentId: 'b' }),
|
||||
b: summary('b', { origin: 'subagent', parentId: 'a' }),
|
||||
}
|
||||
expect(treeSessionIds(byId, 'root').size).toBe(1)
|
||||
expect(treeSessionIds(byId, undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeTasks', () => {
|
||||
it('collects tasks of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const tasksBySession = {
|
||||
root: [task('bash-1')],
|
||||
child: [task('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [task('bash-9')],
|
||||
}
|
||||
const rows = collectTreeTasks(byId, tasksBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.task.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent mirror or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeTasks(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeTasks(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderTasks', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarTaskStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
task: task(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
|
||||
})
|
||||
const rows = [
|
||||
row('old-settled', 'completed', 1_000, 2_000),
|
||||
row('live-2', 'running', 4_000),
|
||||
row('new-settled', 'killed', 1_500, 1_800),
|
||||
row('live-1', 'stopping', 3_000),
|
||||
]
|
||||
expect(orderTasks(rows).map(r => r.task.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isTaskLive(task('a', { status: 'running' }))).toBe(true)
|
||||
expect(isTaskLive(task('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isTaskLive(task('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isTaskLive(task('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isTaskLive(task('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(taskDotState('running')).toBe('ongoing')
|
||||
expect(taskDotState('stopping')).toBe('warning')
|
||||
expect(taskDotState('completed')).toBe('done')
|
||||
expect(taskDotState('killed')).toBe('warning')
|
||||
expect(taskDotState('failed')).toBe('error')
|
||||
expect(taskStatusLabel('running', t)).toBe('taskStatusRunning')
|
||||
expect(taskStatusLabel('stopping', t)).toBe('taskStatusStopping')
|
||||
expect(taskStatusLabel('completed', t)).toBe('taskStatusCompleted')
|
||||
expect(taskStatusLabel('killed', t)).toBe('taskStatusKilled')
|
||||
expect(taskStatusLabel('failed', t)).toBe('taskStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatTaskDuration(0, t)).toBe('0秒')
|
||||
expect(formatTaskDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatTaskDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatTaskDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatTaskDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewTask', () => {
|
||||
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
tasksBySession: tasksBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new task id for the session (not just the first)', () => {
|
||||
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1'), task('bash-2')] }),
|
||||
'root',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
|
||||
// Settling only mutates status, never adds ids.
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
|
||||
// Tasks owned by another session do not trigger the current one.
|
||||
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Host route tests for the background-task API ('tasks.output' / 'tasks.kill').
|
||||
* 'tasks.output' REPLAYS the output the model has read so far from the owner
|
||||
* session's event log (tool/call + tool/result pairs of task_output calls) —
|
||||
* the model's cursor is never touched, so nothing is stolen and unread tasks
|
||||
* report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced
|
||||
* by the owning session, with a 503 when the registry is absent.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildTasksApi } from '../src/tasks-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
/** A context whose `get` serves only the tasks/agents faces, with a session store. */
|
||||
function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context {
|
||||
return {
|
||||
sessions,
|
||||
get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** A context that additionally captures the session/event listener (live mirror). */
|
||||
function ctxWithFeed(sessions: unknown): {
|
||||
ctx: Context
|
||||
emit: (session: unknown, event: SidebarSessionEvent) => void
|
||||
} {
|
||||
let listener: ((session: unknown, event: SidebarSessionEvent) => void) | undefined
|
||||
const base = ctxWith(sessions, undefined, undefined) as unknown as {
|
||||
on: (event: string, fn: (session: unknown, event: SidebarSessionEvent) => void) => () => void
|
||||
effect: (fn: () => void | (() => void)) => void
|
||||
}
|
||||
base.on = (_event: string, fn) => {
|
||||
listener = fn
|
||||
return () => { if (listener === fn) listener = undefined }
|
||||
}
|
||||
// The vendored cordis runs the registration effect immediately.
|
||||
base.effect = (fn) => { fn() }
|
||||
return {
|
||||
ctx: base as unknown as Context,
|
||||
emit: (session, event) => { listener?.(session, event) },
|
||||
}
|
||||
}
|
||||
|
||||
/** A stub live agent (the fence compares `id` only). */
|
||||
const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } })
|
||||
|
||||
/** One task_output tool/call event. */
|
||||
function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent {
|
||||
return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } }
|
||||
}
|
||||
|
||||
/** One tool/result event carrying the finalized text the model received. */
|
||||
function taskOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq,
|
||||
time: seq,
|
||||
data: {
|
||||
message: {
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
isError: over.isError === true,
|
||||
content: [{ type: 'text', text }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner session with the given event log. */
|
||||
function session(events: SidebarSessionEvent[]): unknown {
|
||||
return { header: { cwd: '/p' }, events }
|
||||
}
|
||||
|
||||
describe('tasks.output route (event replay)', () => {
|
||||
it('concatenates the task_output results the model read for the task, oldest first', () => {
|
||||
const events = [
|
||||
taskOutputCall(0, 'c1', 'bash-1'),
|
||||
taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
|
||||
taskOutputCall(2, 'c2', 'bash-1'),
|
||||
taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
|
||||
// Another task's reads are ignored.
|
||||
taskOutputCall(4, 'c3', 'bash-2'),
|
||||
taskOutputResult(5, 'c3', 'other task output'),
|
||||
]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the controller\'s "(no new output)" deltas and error results', () => {
|
||||
const events = [
|
||||
taskOutputCall(0, 'c1', 'bash-1'),
|
||||
taskOutputResult(1, 'c1', 'real output\n[status: running]'),
|
||||
taskOutputCall(2, 'c2', 'bash-1'),
|
||||
taskOutputResult(3, 'c2', '(no new output)\n[status: running]'),
|
||||
taskOutputCall(4, 'c3', 'bash-1'),
|
||||
taskOutputResult(5, 'c3', 'boom', { isError: true }),
|
||||
]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'real output\n[status: running]',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports read:false until the model reads the task (no registry call at all)', () => {
|
||||
const events = [taskOutputCall(0, 'c1', 'bash-2')]
|
||||
const tasks = { kill: vi.fn() }
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
// The replay never touches the registry — the model's cursor is safe by construction.
|
||||
expect(tasks.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors live task_output events the store log misses (restart divergence)', () => {
|
||||
// The store session is frozen at its rehydration boundary (no events);
|
||||
// the read exists only on the live session/event feed.
|
||||
const { ctx, emit } = ctxWithFeed({ get: () => session([]) })
|
||||
const api = buildTasksApi(ctx, 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
|
||||
// The live feed delivers the task_output call and its result.
|
||||
emit({ id: 's1' }, taskOutputCall(100, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, taskOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'live line\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
|
||||
// Unrelated results are never cached (no task_output call paired them),
|
||||
// and another session's feed does not leak into this one.
|
||||
emit({ id: 's1' }, taskOutputResult(102, 'c-other', 'unpaired line'))
|
||||
emit({ id: 's2' }, taskOutputCall(103, 'c-s2', 'bash-1'))
|
||||
emit({ id: 's2' }, taskOutputResult(104, 'c-s2', 'other session line'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'live line\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
expect(api.output({ sessionId: 's2', id: 'bash-1' })).toEqual({
|
||||
text: 'other session line',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('merges store-log traces with live mirror traces without double-counting', () => {
|
||||
// A seed read (seq 5) in the store log plus a live read (seq 106) that
|
||||
// ALSO reached the store log would double-count — the seq dedupe keeps
|
||||
// exactly one copy of each.
|
||||
const events = [taskOutputCall(5, 'c-seed', 'bash-1'), taskOutputResult(6, 'c-seed', 'seed line')]
|
||||
const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
|
||||
const api = buildTasksApi(ctx, 512 * 1024)
|
||||
emit({ id: 's1' }, taskOutputCall(106, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, taskOutputResult(107, 'c-live', 'live line'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'seed line\nlive line',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('caps oversized replays with the truncated flag', () => {
|
||||
const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
|
||||
const value = api.output({ sessionId: 's1', id: 'bash-1' })
|
||||
expect(value.text).toBe('x'.repeat(100))
|
||||
expect(value.truncated).toBe(true)
|
||||
expect(value.read).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a missing sessionId or id as bad-request', () => {
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
|
||||
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('tasks.kill route', () => {
|
||||
it('kills with the forwarded reason and the live caller', () => {
|
||||
const tasks = { kill: vi.fn(() => 'requested' as const) }
|
||||
const agents = { get: vi.fn((id: string) => agent(id)) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, agents), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
|
||||
.toEqual({ ok: true, outcome: 'requested' })
|
||||
expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
|
||||
})
|
||||
|
||||
it('defaults the reason when none is supplied', () => {
|
||||
const tasks = { kill: vi.fn(() => 'already-finished' as const) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
|
||||
expect(tasks.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
|
||||
})
|
||||
|
||||
it('maps registry refusals to a 404 task-error', () => {
|
||||
const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 404 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => {
|
||||
const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 503 }),
|
||||
)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
})
|
||||
})
|
||||
+609
-32
@@ -3,8 +3,8 @@ import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
@@ -15,6 +15,9 @@ import { isPdfExt } from '../src/client/pdf-types.ts'
|
||||
import { isImageExt } from '../src/client/image-types.ts'
|
||||
import { relativeTo } from '../src/client/paths.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
|
||||
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||
import {
|
||||
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
|
||||
@@ -214,23 +217,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)
|
||||
s = openTabInActivePane(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 = openTabInActivePane(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 = openTabInActivePane(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'])
|
||||
s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
|
||||
const after = openTabInActivePane(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', 'e2'])
|
||||
})
|
||||
|
||||
const diffTab = (id: string): SidebarTab => ({
|
||||
@@ -243,7 +249,7 @@ describe('sidebar state', () => {
|
||||
it('first diff splits the source pane vertically (diff below)', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
expect(after.splits.kind).toBe('split')
|
||||
@@ -259,7 +265,7 @@ describe('sidebar state', () => {
|
||||
it('reopening the same diff focuses its existing tab', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
@@ -273,7 +279,7 @@ describe('sidebar state', () => {
|
||||
it('subsequent diffs stack into the existing diff pane', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const withGit = openTabInActivePane(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
|
||||
@@ -338,10 +344,10 @@ describe('sidebar state', () => {
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
|
||||
const after = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
|
||||
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
@@ -372,7 +378,7 @@ describe('sidebar state', () => {
|
||||
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
|
||||
const tabId = paneA.tabs[0]!.id
|
||||
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
|
||||
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
|
||||
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
|
||||
@@ -398,7 +404,7 @@ describe('sidebar state', () => {
|
||||
|
||||
it('dragging a tab back onto its own pane center reorders it', () => {
|
||||
let s = state()
|
||||
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
const first = leaf.tabs[0]!.id
|
||||
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
|
||||
@@ -416,7 +422,7 @@ describe('sidebar state', () => {
|
||||
const explorerId = paneA.tabs[0]!.id
|
||||
// paneA gets a terminal; the explorer moves to paneB; closing the
|
||||
// terminal empties paneA, which is removed, promoting paneB.
|
||||
s = openTab(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
|
||||
s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
|
||||
s = moveTab(s, paneA.id, explorerId, paneB.id)
|
||||
s = activateTab(s, paneA.id, 't')
|
||||
s = closeTab(s, paneA.id, 't')
|
||||
@@ -448,6 +454,52 @@ describe('sidebar state', () => {
|
||||
expect((after.splits as { active: string | null }).active).toBe(tabId)
|
||||
})
|
||||
|
||||
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
|
||||
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
|
||||
const browserId = 'browser:1'
|
||||
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
|
||||
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
|
||||
// A partial patch leaves the other field untouched.
|
||||
s = patchTab(s, browserId, { title: 'example.org' })
|
||||
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
|
||||
// Other tabs are untouched.
|
||||
expect(leaf.tabs[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('patchTab is a no-op for a missing tab id', () => {
|
||||
const s = state()
|
||||
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
|
||||
expect(after).toBe(s)
|
||||
})
|
||||
|
||||
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
|
||||
const base = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: null,
|
||||
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
}
|
||||
// Older persisted states lack the field: they must keep loading.
|
||||
expect(sanitizeState(base)?.nextBrowser).toBe(1)
|
||||
// A present valid value survives; a malformed one falls back to 1.
|
||||
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
|
||||
// The default state seeds 1.
|
||||
expect(makeDefaultState().nextBrowser).toBe(1)
|
||||
})
|
||||
|
||||
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
@@ -465,9 +517,266 @@ describe('sidebar state', () => {
|
||||
s = closeTab(s, target.id, explorerId)
|
||||
expect(tabOpenIn(s, explorerId)).toBe(false)
|
||||
// A terminal tab added later is open too.
|
||||
s = openTab(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
|
||||
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
|
||||
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
|
||||
})
|
||||
|
||||
// ── Bottom panel (the second, independent workbench) ───────────────────
|
||||
|
||||
it('toggleBottomPanel flips the bottom panel independently of the right panel', () => {
|
||||
let s = state()
|
||||
expect(s.bottomOpen).toBe(false)
|
||||
s = toggleBottomPanel(s)
|
||||
expect(s.bottomOpen).toBe(true)
|
||||
// Collapsing the right panel leaves the bottom panel open (independent toggles).
|
||||
s = togglePanel(s)
|
||||
expect(s.panelOpen).toBe(false)
|
||||
expect(s.bottomOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('setBottomHeight clamps to the contract range', () => {
|
||||
expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const previous = g.window
|
||||
g.window = { innerHeight: 800 }
|
||||
try {
|
||||
// The bottom panel must leave the center column at least PANEL_MIN
|
||||
// tall (800 - 280), regardless of the right panel's open state.
|
||||
expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280)
|
||||
expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280)
|
||||
} finally {
|
||||
if (previous === undefined) delete g.window
|
||||
else g.window = previous
|
||||
}
|
||||
})
|
||||
|
||||
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
|
||||
|
||||
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
// Two bottom tabs in their own pane; the right pane holds explorer.
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
|
||||
const migrated = migrateBottomTabs(s)
|
||||
// All tabs now live in the right tree's first leaf, bottom tabs appended.
|
||||
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
|
||||
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
|
||||
// The bottom tree is emptied (structure stays), the panel closes, and
|
||||
// new tabs land in the right tree.
|
||||
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(migrated.bottomOpen).toBe(false)
|
||||
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||
// The migrated tabs are fully functional: closing one works through the
|
||||
// right tree.
|
||||
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
|
||||
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row') // splits the active pane into two leaves
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
|
||||
const migrated = migrateBottomTabs(s)
|
||||
// The first (leftmost) leaf carries the bottom tab; the second leaf
|
||||
// keeps its own tabs untouched.
|
||||
const leaves = allLeaves(migrated.splits)
|
||||
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
|
||||
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
|
||||
const s = state()
|
||||
expect(migrateBottomTabs(s)).toBe(s)
|
||||
// With the panel open but no tabs, the migration only closes the panel.
|
||||
const open = toggleBottomPanel(s)
|
||||
const migrated = migrateBottomTabs(open)
|
||||
expect(migrated).not.toBe(open)
|
||||
expect(migrated.bottomOpen).toBe(false)
|
||||
expect(migrateBottomTabs(migrated)).toBe(migrated)
|
||||
})
|
||||
|
||||
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
|
||||
let s = state()
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
|
||||
const migrated = migrateBottomTabs(s)
|
||||
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||
// A tab opened after the migration lands in the VISIBLE right tree.
|
||||
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
|
||||
})
|
||||
|
||||
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane }
|
||||
const tab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
s = openTabInActivePane(s, tab)
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git')
|
||||
// The right tree is untouched.
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer'])
|
||||
expect(s.activePane).toBe(bottomPane)
|
||||
// The id safety net works across trees: reopening the same id focuses it.
|
||||
const after = openTabInActivePane(s, tab)
|
||||
expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git'])
|
||||
})
|
||||
|
||||
it('openTabInActivePane falls back to the right tree when the active pane is stale', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
s = { ...s, activePane: 'pane:gone' }
|
||||
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
|
||||
})
|
||||
|
||||
it('closeTab routes to the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
s = closeTab(s, bottomPane, 'terminal:1')
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(false)
|
||||
// The right tree is untouched.
|
||||
expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('moveTabToEdge splits within the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right')
|
||||
expect(s.bottomSplits.kind).toBe('split')
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
// The fresh leaf (the drop's active pane) differs from the source pane.
|
||||
expect(s.activePane).not.toBe(bottomPane)
|
||||
})
|
||||
|
||||
it('resizeSplitIn routes a divider to its own tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = splitPane({ ...s, activePane: bottomPane }, 'row')
|
||||
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
s = resizeSplitIn(s, split.id, 0, 0.1)
|
||||
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(next.sizes[0]).toBeCloseTo(0.6)
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
})
|
||||
|
||||
it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => {
|
||||
const base = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: null,
|
||||
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
}
|
||||
// Older persisted states lack the bottom fields: defaults, state kept.
|
||||
const s = sanitizeState(base)
|
||||
expect(s?.bottomOpen).toBe(false)
|
||||
expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT)
|
||||
expect(s?.bottomSplits.kind).toBe('leaf')
|
||||
expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
// A malformed bottom tree is replaced with a fresh empty pane.
|
||||
const broken = sanitizeState({ ...base, bottomSplits: 'junk' })
|
||||
expect(broken?.splits).toBeDefined()
|
||||
expect(broken?.bottomSplits.kind).toBe('leaf')
|
||||
// A valid persisted bottom tree survives.
|
||||
const withBottom = sanitizeState({
|
||||
...base,
|
||||
bottomOpen: true,
|
||||
bottomHeight: 300,
|
||||
bottomSplits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:9',
|
||||
active: 'b1',
|
||||
tabs: [{ id: 'b1', type: 'terminal', title: 'T' }],
|
||||
},
|
||||
})
|
||||
expect(withBottom?.bottomOpen).toBe(true)
|
||||
expect(withBottom?.bottomHeight).toBe(300)
|
||||
expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1'])
|
||||
// Heights are clamped to the contract range.
|
||||
expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN)
|
||||
// A stale full-height bottom panel must not squeeze the center column
|
||||
// (the agent output area) to zero: the cap leaves it at least PANEL_MIN
|
||||
// tall, regardless of the right panel's open state.
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const previous = g.window
|
||||
g.window = { innerHeight: 800 }
|
||||
try {
|
||||
expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
|
||||
expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
|
||||
} finally {
|
||||
if (previous === undefined) delete g.window
|
||||
else g.window = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('tabOpenIn and patchTab see tabs in the bottom tree', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = openTabInActivePane(
|
||||
{ ...s, activePane: bottomPane },
|
||||
{ id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' },
|
||||
)
|
||||
expect(tabOpenIn(s, 'browser:1')).toBe(true)
|
||||
s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' })
|
||||
const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1')
|
||||
expect(tab?.title).toBe('other.com')
|
||||
})
|
||||
|
||||
it('moves a tab across panels (center merge into the other tree)', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const rightPane = (s.splits as { id: string }).id
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
// Drag the explorer tab from the right panel into the bottom panel (center).
|
||||
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center')
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId)
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(s.activePane).toBe(bottomPane)
|
||||
// And back, inserted at an index.
|
||||
s = moveTab(s, bottomPane, explorerId, rightPane, 0)
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId)
|
||||
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('moves a tab across panels by splitting the target pane (edge drop)', () => {
|
||||
let s = state()
|
||||
s = toggleBottomPanel(s)
|
||||
const rightPane = (s.splits as { id: string }).id
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
|
||||
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right')
|
||||
// The source tree empties back to a leaf; the target tree splits.
|
||||
expect(s.splits.kind).toBe('leaf')
|
||||
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||
expect(s.bottomSplits.kind).toBe('split')
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
|
||||
expect(split.children.some(
|
||||
child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId),
|
||||
)).toBe(true)
|
||||
// The fresh leaf (the drop's active pane) differs from the source pane.
|
||||
expect(s.activePane).not.toBe(rightPane)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent terminal reconciliation', () => {
|
||||
@@ -508,7 +817,7 @@ describe('agent terminal reconciliation', () => {
|
||||
|
||||
it('does not touch non-agent terminal tabs', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = openTab(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
|
||||
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
|
||||
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
@@ -524,6 +833,17 @@ describe('agent terminal reconciliation', () => {
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
|
||||
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
|
||||
})
|
||||
|
||||
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
|
||||
let s = makeDefaultState(400, true)
|
||||
s = toggleBottomPanel(s)
|
||||
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||
s = { ...s, activePane: bottomPane }
|
||||
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
|
||||
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
|
||||
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pty helpers', () => {
|
||||
@@ -644,10 +964,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'
|
||||
@@ -679,7 +1005,7 @@ describe('persisted state sanitization', () => {
|
||||
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
|
||||
expect(clean!.activePane).toBe(leaves[1]!.id)
|
||||
// And an open must land in exactly one pane of the healed tree.
|
||||
const opened = openTab(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
|
||||
expect(owners).toHaveLength(1)
|
||||
})
|
||||
@@ -689,13 +1015,13 @@ describe('persisted state sanitization', () => {
|
||||
const paneA = allLeaves(s.splits)[0]!.id
|
||||
const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id
|
||||
s = closeTab(s, paneA, explorerTab)
|
||||
s = openTab(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
|
||||
s = { ...s, splits: split.node, activePane: paneA }
|
||||
// Closing the editor empties paneA; the pane is removed but activePane
|
||||
// still points at it. The next open must land in the surviving pane.
|
||||
s = closeTab(s, paneA, 'editor:/a.ts')
|
||||
s = openTab(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
|
||||
s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
|
||||
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
|
||||
expect(owners).toHaveLength(1)
|
||||
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
|
||||
@@ -820,17 +1146,59 @@ describe('side card preferences', () => {
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true })
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
autoOpenTasks: true,
|
||||
agentTerminalTools: true,
|
||||
bottomPanelAutoTerminal: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
.toEqual({
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
.toEqual({
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
|
||||
@@ -840,15 +1208,43 @@ describe('side card preferences', () => {
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
// The task auto-open is ON by default; only an explicit false turns it off.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: 1 }))).autoOpenTasks)
|
||||
.toBe(true)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: false }))).autoOpenTasks)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
|
||||
// Absent or malformed → on (the takeover is the safe default).
|
||||
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
|
||||
})
|
||||
|
||||
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
|
||||
// A non-object map falls back to {} (everything enabled).
|
||||
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
|
||||
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
|
||||
// Non-boolean entries are dropped; boolean entries survive verbatim.
|
||||
const parsed = await loadPrefs(wire({
|
||||
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
|
||||
viewersEnabled: { image: false, code: 1 },
|
||||
}))
|
||||
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
|
||||
expect(parsed.viewersEnabled).toEqual({ image: false })
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -859,6 +1255,45 @@ describe('side card preferences', () => {
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
|
||||
// Stub a narrow window (the file otherwise runs without one): only
|
||||
// innerWidth is read while seeding a fresh session.
|
||||
const original = (globalThis as Record<string, unknown>).window
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
innerWidth: 390,
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
}
|
||||
try {
|
||||
const store = createSidebarStore()
|
||||
// Default prefs say openByDefault: true — the narrow viewport overrides
|
||||
// it for the FIRST seeding only (a later user expansion persists).
|
||||
store.setSession('narrow-fresh')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
// The width seeding still follows the window (clamped to the floor).
|
||||
expect(store.getSnapshot().state?.width).toBe(280)
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as Record<string, unknown>).window
|
||||
else (globalThis as Record<string, unknown>).window = original
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(0)
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
})
|
||||
|
||||
it('derives the default width from the window percent with clamps', () => {
|
||||
expect(defaultWidthFor(1440, 30)).toBe(432)
|
||||
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
|
||||
@@ -869,6 +1304,9 @@ describe('side card preferences', () => {
|
||||
expect(makeDefaultState().panelOpen).toBe(true)
|
||||
expect(makeDefaultState(400, false).panelOpen).toBe(false)
|
||||
expect(makeDefaultState(400, false).width).toBe(400)
|
||||
// The seedExplorer flag controls the default explorer tab.
|
||||
expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf')
|
||||
expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1041,3 +1479,142 @@ describe('subagent activity summary parser', () => {
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('open-path interception', () => {
|
||||
/** A minimal fake of the workspaces.openPath service method. */
|
||||
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
|
||||
const fake = {
|
||||
calls: [] as string[],
|
||||
opened: [] as string[],
|
||||
async openPath(path: string): Promise<void> {
|
||||
this.calls.push(path)
|
||||
this.opened.push(path)
|
||||
},
|
||||
}
|
||||
return fake
|
||||
}
|
||||
|
||||
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
|
||||
sidebar: string[]
|
||||
} => {
|
||||
const sidebar: string[] = []
|
||||
return {
|
||||
sidebar,
|
||||
takeoverEnabled: () => true,
|
||||
currentSessionId: () => 's1',
|
||||
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
|
||||
const ws = service()
|
||||
const d = deps()
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
|
||||
expect(ws.calls).toEqual([])
|
||||
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('falls through to the original when the takeover is disabled', async () => {
|
||||
const ws = service()
|
||||
const d = deps({ takeoverEnabled: () => false })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
expect(d.sidebar).toEqual([])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
|
||||
const ws = service()
|
||||
const d = deps({ currentSessionId: () => undefined })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
expect(d.sidebar).toEqual([])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('passes the current session into the sidebar opener', async () => {
|
||||
const ws = service()
|
||||
let current = 's1'
|
||||
const d = deps({ currentSessionId: () => current })
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
current = 's2'
|
||||
await ws.openPath('/abs/b.ts')
|
||||
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
|
||||
restore()
|
||||
})
|
||||
|
||||
it('restores the original method on dispose (HMR-safe)', async () => {
|
||||
const ws = service()
|
||||
const d = deps()
|
||||
const original = ws.openPath
|
||||
const restore = wrapOpenPath(ws, d)
|
||||
expect(ws.openPath).not.toBe(original)
|
||||
restore()
|
||||
expect(ws.openPath).toBe(original)
|
||||
await ws.openPath('/abs/a.ts')
|
||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
||||
})
|
||||
|
||||
it('treats a rejected promise like the original would (no swallowing)', async () => {
|
||||
const failing: OpenPathService = {
|
||||
async openPath() { throw new Error('host refused') },
|
||||
}
|
||||
const d = deps({ takeoverEnabled: () => false })
|
||||
const restore = wrapOpenPath(failing, d)
|
||||
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
|
||||
restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('open-path interception wiring', () => {
|
||||
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
|
||||
// A realistic client-context fake: the sessions list feed (current + cwd),
|
||||
// the workspaces funnel, and the sidebar service the editor goes through.
|
||||
const opened: Array<Record<string, unknown>> = []
|
||||
const funnel = { openPath: async (): Promise<void> => {} }
|
||||
const ctx = {
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
|
||||
},
|
||||
workspaces: funnel,
|
||||
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
|
||||
} as unknown as Context
|
||||
const store = createSidebarStore()
|
||||
const original = ctx.workspaces.openPath
|
||||
const restore = registerOpenPathInterception(ctx, store)
|
||||
|
||||
// Default prefs: the takeover routes the open into the sidebar editor
|
||||
// with the session-scoped absolute path (chat already resolved it).
|
||||
await ctx.workspaces.openPath('/w/src/a.ts')
|
||||
expect(opened).toEqual([{
|
||||
type: 'editor',
|
||||
title: 'a.ts',
|
||||
path: '/w/src/a.ts',
|
||||
id: 'editor:/w/src/a.ts',
|
||||
}])
|
||||
|
||||
// The interceptOpenPath pref off → the original funnel runs untouched.
|
||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
||||
const calls: string[] = []
|
||||
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
|
||||
await ctx.workspaces.openPath('/w/src/b.ts')
|
||||
expect(calls).toEqual(['/w/src/b.ts'])
|
||||
expect(opened).toHaveLength(1)
|
||||
|
||||
// The editor tab disabled → falls through too (an editor that cannot
|
||||
// open must not swallow opens).
|
||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
|
||||
await ctx.workspaces.openPath('/w/src/c.ts')
|
||||
expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
|
||||
|
||||
// Disposal restores the raw original method (HMR-safe).
|
||||
restore()
|
||||
expect(ctx.workspaces.openPath).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -16,5 +16,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "tests", "tsdown.config.ts"]
|
||||
"include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
+148
-68
@@ -27,6 +27,17 @@
|
||||
* - each artifact registers itself via window.__ModuleLoader__.load({id,
|
||||
* factory}) with the (require) => exports CJS closure shape.
|
||||
*
|
||||
* Lazy chunks (lib/client-<name>.js): the heavy preview/terminal libraries
|
||||
* (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — tens of MB)
|
||||
* build as five standalone chunk bundles (src/client/chunks/<name>.tsx),
|
||||
* shared by both channels. Each script assigns its factory to the
|
||||
* plugin-owned global registry (globalThis.__dshChunks__) and is fetched by
|
||||
* the client on first use from the plugin's own /sidebar/bundle route —
|
||||
* chunks deliberately do NOT go through the module loader (see
|
||||
* src/client/chunk-loader.ts). `codeSplitting: false` keeps every chunk a
|
||||
* single script; the core client.js must never statically import a chunks/
|
||||
* entry.
|
||||
*
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
@@ -155,71 +166,7 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
},
|
||||
// External wins for module-table entries; every other dependency inlines.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [{
|
||||
// Bundle purity gate (mirror of the official preset): platform seed
|
||||
// entries stay external, inline-safe wire layers inline, and every
|
||||
// other @deepseek-ai value import is a build error — a cross-plugin
|
||||
// value import either inlines a duplicate runtime instance or requires
|
||||
// a specifier the frozen module table cannot answer. Cross-plugin
|
||||
// collaboration goes through cordis services instead. Type-only
|
||||
// imports are erased and never reach this gate.
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (NODE_BUILTINS.has(source)) {
|
||||
throw new Error(
|
||||
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||
)
|
||||
}
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
// Relative/absolute paths resolve against the importer; bare
|
||||
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
|
||||
let abs: string
|
||||
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
||||
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
||||
} else {
|
||||
abs = require.resolve(source)
|
||||
}
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
// CSS Modules (x.module.css) become hashed class maps; plain css
|
||||
// (xterm's stylesheet) is inlined verbatim.
|
||||
if (fileId.endsWith('.module.css')) {
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
return [
|
||||
injectTag(pluginId, fileId, code.toString()),
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
injectTag(pluginId, fileId, source.toString('utf8')),
|
||||
'export default "";',
|
||||
].join('\n')
|
||||
},
|
||||
}],
|
||||
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
|
||||
outputOptions: {
|
||||
entryFileNames: entryFile,
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
@@ -228,14 +175,144 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
// The CJS wrapper factory's `require` only resolves module-table entries
|
||||
// (react, cordis, ...); it cannot load relative chunk URLs in the browser.
|
||||
// Disable code splitting so dynamic import() inlines into the single
|
||||
// factory chunk (Office preview libs — docx-preview, Univer — pull in
|
||||
// several MB but only when first opened, the trade-off for wrapper safety).
|
||||
// Disable code splitting so every artifact is one script (the lazy chunk
|
||||
// files themselves are separate bundles — see chunkBundle below).
|
||||
codeSplitting: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One lazy chunk bundle: a heavy feature slice of the client built as a
|
||||
* standalone single script (lib/client-<name>.js), fetched by the client on
|
||||
* first use through the plugin's /sidebar/bundle route. The core bundle must
|
||||
* never statically import the chunk entry.
|
||||
*
|
||||
* Chunks do NOT register with window.__ModuleLoader__: the module loader's
|
||||
* import() resolves seed words / shell-own modules / registered factories /
|
||||
* boot graph rows, and a chunk id is none of those — resolution would be
|
||||
* version-dependent. Instead each script assigns its CJS factory to the
|
||||
* plugin-owned global registry `globalThis.__dshChunks__[<name>]`, and the
|
||||
* loader (src/client/chunk-loader.ts) materializes it with a require built
|
||||
* from the module table's seed words.
|
||||
*
|
||||
* Chunk css tags use the constant plugin id `dsh-better-sidebar` (matching
|
||||
* the official channel; the registry channel re-injects an identical copy
|
||||
* of the shared module css — same content, no functional impact).
|
||||
* @param name - chunk name; entry src/client/chunks/<name>.tsx, output
|
||||
* lib/client-<name>.js. Keep in sync with CHUNK_NAMES in src/bundle-route.ts.
|
||||
*/
|
||||
function chunkBundle(name: string): UserConfig {
|
||||
return {
|
||||
entry: { [name]: `src/client/chunks/${name}.tsx` },
|
||||
outDir: 'lib',
|
||||
format: 'cjs',
|
||||
platform: 'browser',
|
||||
dts: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
'import.meta.resolve': 'undefined',
|
||||
},
|
||||
alias: {
|
||||
jszip: JSZIP_BROWSER_ENTRY,
|
||||
xlsx: XLSX_BROWSER_ENTRY,
|
||||
},
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
},
|
||||
},
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [purityGatePlugin(), makeCssPlugin('dsh-better-sidebar')],
|
||||
outputOptions: {
|
||||
entryFileNames: `client-${name}.js`,
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `globalThis.__dshChunks__ = globalThis.__dshChunks__ || {}; globalThis.__dshChunks__[${JSON.stringify(name)}] = (require) => {`,
|
||||
footer: 'return module.exports; };',
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
codeSplitting: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
|
||||
type BuildPlugin = NonNullable<UserConfig['plugins']>
|
||||
|
||||
/** The shared client-bundle purity gate (see the clientBundle doc). */
|
||||
function purityGatePlugin(): BuildPlugin {
|
||||
return {
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (NODE_BUILTINS.has(source)) {
|
||||
throw new Error(
|
||||
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||
)
|
||||
}
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
|
||||
function makeCssPlugin(pluginId: string): BuildPlugin {
|
||||
return {
|
||||
name: 'dsh-css-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
// Relative/absolute paths resolve against the importer; bare
|
||||
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
|
||||
let abs: string
|
||||
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
|
||||
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
|
||||
} else {
|
||||
abs = require.resolve(source)
|
||||
}
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
// CSS Modules (x.module.css) become hashed class maps; plain css
|
||||
// (xterm's stylesheet) is inlined verbatim.
|
||||
if (fileId.endsWith('.module.css')) {
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
return [
|
||||
injectTag(pluginId, fileId, code.toString()),
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
injectTag(pluginId, fileId, source.toString('utf8')),
|
||||
'export default "";',
|
||||
].join('\n')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
|
||||
export default [
|
||||
{
|
||||
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
|
||||
@@ -254,4 +331,7 @@ export default [
|
||||
clientBundle('dsh-better-sidebar', 'client.js'),
|
||||
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
|
||||
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
|
||||
// Lazy chunks: shared by both channels, fetched on first use through the
|
||||
// plugin's /sidebar/bundle route (see src/client/chunk-loader.ts).
|
||||
...CHUNKS.map(chunkBundle),
|
||||
] satisfies UserConfig[]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Vitest config: inline the npm-published `@deepseek-ai/*` packages whose
|
||||
* BUILT lib bundles css side-effect imports (e.g. `dsh-client-ui-primitives`
|
||||
* imports `katex/dist/katex.min.css` at the top of its `lib/index.js`).
|
||||
*
|
||||
* Installed from the npm registry (the default since v0.4.1) these packages
|
||||
* live under `node_modules/.pnpm` and are externalized by vitest — Node then
|
||||
* chokes on the `.css` import. Inlining routes them through Vite's transform,
|
||||
* which stubs css imports (the default `css: false`). The previous
|
||||
* `link:`-to-source-checkout install needed no such config: linked files sit
|
||||
* outside `node_modules` and are transformed by default.
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user