Merge pull request #4 from dsh-external/feat/modular-registry
refactor: 全插件注册化收尾 — 审查修正 + 8/8 viewer 吃狗粮 + 模块化 builtins/
This commit is contained in:
@@ -78,7 +78,7 @@ export function apply(ctx: Context): void {
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service` exports 子路径暴露。
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
|
||||
---
|
||||
|
||||
@@ -98,12 +98,17 @@ interface TabDescriptor {
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满) */
|
||||
available?: (state: SidebarState) => 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 表示不去重(每次都新开)。
|
||||
* 内置策略:explorer/git/subagent 用 () => id(单实例);editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
@@ -122,7 +127,7 @@ interface TabDescriptor {
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce/openTab 等)
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? }
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
@@ -146,7 +151,7 @@ ctx.effect(() =>
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:notes', // 单实例
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
@@ -230,6 +235,7 @@ interface FileViewerProps {
|
||||
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' 时
|
||||
@@ -249,16 +255,17 @@ interface FileViewerProps {
|
||||
|
||||
### 4.4 匹配算法
|
||||
|
||||
`matchFileViewer(path, head?)` 按以下顺序解析:
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. **priority 降序**(稳定排序,相同 priority 按注册顺序)
|
||||
2. **detect 嗅探**:若 `head` 字节可用,遍历 priority 降序的 viewer,第一个 `detect(path, head)` 返回 true 的命中(**覆盖 exts**)
|
||||
3. **exts 匹配**:第一个 `exts` 含目标扩展名的 viewer 命中(`exts: []` 跳过此轮)
|
||||
4. **catch-all**:第一个 `exts: []` 的 viewer 命中
|
||||
5. **fallback**:返回 `undefined`,EditorView 走 CodeMirror 兜底(按扩展名选语法高亮)
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
|
||||
|
||||
> **内置 viewer**(不可重复注册):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / binary-download(-50)。
|
||||
> code/markdown **没有**注册为 viewer,是 EditorView 的兜底逻辑(matchFileViewer 返回 undefined 时走 fsRead + CodeMirror/MarkdownText)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
|
||||
@@ -324,10 +331,14 @@ interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 按 path 匹配 file viewer(priority desc → detect → exts) */
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重) */
|
||||
openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
@@ -439,9 +450,10 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins.tsx`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 6 个内置 viewer(image/pdf/docx/xlsx/pptx/binary-download)的注册代码
|
||||
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`tests/service.spec.ts`**:12 个测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -293,7 +293,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
- **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })`
|
||||
- **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })`
|
||||
|
||||
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 6 个 viewer(image/pdf/docx/xlsx/pptx/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。
|
||||
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 8 个 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。预览路由、+ 菜单、tab 图标全部由注册表驱动,无硬编码分发。
|
||||
|
||||
**消费插件最小骨架**:
|
||||
```ts
|
||||
|
||||
@@ -359,3 +359,58 @@ export function apply(ctx: Context): void {
|
||||
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 语义重写。
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function OrphanedTab(props: TabComponentProps): ReactNode {
|
||||
</div>
|
||||
<div className={css.editorPlaceholder}>
|
||||
<span>{t('pluginNotLoaded')}</span>
|
||||
<code style={{ display: 'block', marginTop: 8, fontSize: 12, opacity: 0.7 }}>{tab.type}</code>
|
||||
<code className={css.orphanedType}>{tab.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,10 +15,10 @@ import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import {
|
||||
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
||||
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
@@ -27,7 +27,7 @@ import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
@@ -66,7 +66,7 @@ function TabContent(props: {
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] {
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
@@ -75,7 +75,7 @@ function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] {
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(state) ?? true),
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
@@ -433,7 +433,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state, ctx)}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
/**
|
||||
* 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 { createElement, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.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'; viewer?: FileViewerDescriptor; kind?: 'text' | 'md'; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
export function EditorView(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
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)
|
||||
@@ -52,85 +43,29 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
|
||||
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')
|
||||
const ext = extOfPath(path)
|
||||
// Registry-driven viewer match: image/pdf/office/binary-download etc.
|
||||
// are registered through ctx.betterSidebar; code/markdown have no
|
||||
// registered viewer and fall through to the fsRead + CodeMirror path.
|
||||
const viewer = ctx.betterSidebar?.matchFileViewer(path)
|
||||
if (viewer !== undefined) {
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl':
|
||||
case 'none':
|
||||
setLoad({ status: 'ready', viewer, mediaUrl: mediaUrl(scope, path) })
|
||||
return
|
||||
case 'binary-download':
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
case 'custom':
|
||||
void viewer.load?.(path, scope).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
case 'fsRead':
|
||||
// An fsRead viewer (e.g. external CSV viewer) gets its content
|
||||
// through the host's fs.read and renders through viewer.component.
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') { setLoad({ status: 'binary' }); return }
|
||||
setLoad({ status: 'ready', viewer, content: result.content, truncated: result.truncated })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: code/markdown (no registered viewer) — fsRead + CodeMirror.
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') {
|
||||
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, ctx])
|
||||
}, [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.viewer !== undefined || (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(),
|
||||
@@ -167,7 +102,7 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
// 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).
|
||||
@@ -200,15 +135,14 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
})
|
||||
}
|
||||
|
||||
const previewable = load.status === 'ready' && load.kind === 'md'
|
||||
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
|
||||
const markdown = viewerId === 'markdown'
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
{previewable && (
|
||||
{markdown && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -240,45 +174,20 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
)}
|
||||
{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 && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
{previewable && mode === 'preview' && (
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content ?? ''} />
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
+3
-2
@@ -54,8 +54,9 @@ 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 }
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Built-in tab types and file viewers: the plugin registers its own
|
||||
* built-in pages (explorer / git / terminal / subagent / editor / diff)
|
||||
* and file previewers (image / pdf / docx / xlsx / pptx / binary-download)
|
||||
* through the same {@link BetterSidebarService} external plugins use —
|
||||
* eating its own dogfood. Code/Markdown editing stays inlined in
|
||||
* {@link EditorView} as the catch-all fallback (no matching viewer →
|
||||
* fsRead + CodeMirror/MarkdownText).
|
||||
*/
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { TERMINAL_LIMIT, allLeaves, isAgentTabId, type SidebarState } from './state.ts'
|
||||
import { downloadUrl, mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
import { GitView } from './GitView.tsx'
|
||||
import { DiffTab } from './DiffTab.tsx'
|
||||
import { SubagentView } from './SubagentView.tsx'
|
||||
import { DocxView, XlsxView } from './office-view.tsx'
|
||||
import { PdfView } from './PdfView.tsx'
|
||||
import { PptxView } from './PptxView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx'
|
||||
import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 6 built-in tab descriptors. */
|
||||
function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorView ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
dedupeKey: () => 'explorer',
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
dedupeKey: () => 'git',
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
dedupeKey: () => 'subagent',
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
},
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
component: ({ scope, tab, store }) => (
|
||||
<TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: ({ scope, tab }) => (
|
||||
tab.diff === undefined ? null
|
||||
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */
|
||||
const builtinViewers: readonly FileViewerDescriptor[] = [
|
||||
{
|
||||
id: 'image',
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={url} alt={title} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
component: ({ scope, path }) => (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Register all built-in tabs and viewers with the service. Returns a
|
||||
* disposer that unregisters everything (cordis auto-invokes it on fiber
|
||||
* disposal). The `ctx` is threaded into tab descriptors that need it
|
||||
* (EditorView reads `ctx.betterSidebar` for file-viewer matching).
|
||||
*/
|
||||
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of builtinTabs(ctx)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
for (const viewer of builtinViewers) {
|
||||
disposers.push(service.registerFileViewer(viewer))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,127 @@
|
||||
/**
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / subagent / editor / diff) through the same
|
||||
* {@link BetterSidebarService} external plugins use — eating its own
|
||||
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
|
||||
* mints `terminal:<n>` ids through `createTab`.
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { TerminalView } from '../TerminalView.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 6 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
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,
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
},
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
component: ({ scope, tab, store }) => (
|
||||
<TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: ({ scope, tab }) => (
|
||||
tab.diff === undefined ? null
|
||||
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The 8 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
|
||||
* binary-download), exactly like external plugins register theirs. The
|
||||
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*/
|
||||
import { DocxView, XlsxView } from '../office-view.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { PptxView } from '../PptxView.tsx'
|
||||
import { TextEditor } from '../TextEditor.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 8 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={url} alt={title} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
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} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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,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' }
|
||||
}
|
||||
+3
-20
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* 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) and a diff glyph. Per-tab icons live on the tab
|
||||
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
|
||||
* mapping was registry-ized with the tab types.
|
||||
*/
|
||||
import type { 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'
|
||||
|
||||
/**
|
||||
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
@@ -30,16 +26,3 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</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} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { createBetterSidebarService } from './service.ts'
|
||||
import { registerBuiltins } from './builtins.tsx'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -234,7 +234,7 @@ export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
/**
|
||||
* 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.
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
|
||||
+65
-46
@@ -2,7 +2,7 @@
|
||||
* The BetterSidebar client service: a registry that external plugins use
|
||||
* to contribute sidebar tab types and file previewers. The service is
|
||||
* published to the cordis context as `ctx.betterSidebar` (see
|
||||
* {@link ../types.ts}); consumers declare it in `inject` and call
|
||||
* {@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).
|
||||
*
|
||||
@@ -12,15 +12,17 @@
|
||||
* - `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` resolves viewers by priority (desc), then `detect`
|
||||
* (content sniff), then `exts` (lowercase, no dot).
|
||||
* - `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, firstLeaf, mapLeaf,
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
@@ -52,14 +54,24 @@ export interface TabDescriptor {
|
||||
order?: number
|
||||
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
|
||||
hidden?: boolean
|
||||
/** + menu disabled predicate (e.g. terminal at capacity). */
|
||||
available?: (state: SidebarState) => boolean
|
||||
/**
|
||||
* + 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: explorer/git/subagent use `() => id`; editor uses `tab => tab.path`;
|
||||
* diff uses `tab => tab.id` (openDiffTab mints change-derived ids).
|
||||
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
|
||||
* (openDiffTab mints change-derived ids).
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
@@ -87,6 +99,8 @@ export interface FileViewerProps {
|
||||
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
|
||||
@@ -106,8 +120,8 @@ export interface FileViewerDescriptor {
|
||||
priority?: number
|
||||
fetchStrategy: FileFetchStrategy
|
||||
/**
|
||||
* Content sniff (overrides exts when `head` bytes are available).
|
||||
* The first descriptor (priority desc) whose `detect` returns true wins.
|
||||
* 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. */
|
||||
@@ -123,10 +137,15 @@ export interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** Find a file viewer for a path (priority desc → detect → exts). */
|
||||
/** Find a file viewer for a path (priority desc; detect first, then exts). */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** Open a tab (used by external tabs and the + menu). */
|
||||
openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
@@ -194,29 +213,34 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Priority descending; stable order for equal priorities (insertion order).
|
||||
const sorted = Array.from(viewers.values()).sort(
|
||||
// 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.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)
|
||||
// Pass 1: content sniff (detect overrides exts when head bytes are available).
|
||||
if (head !== undefined) {
|
||||
for (const v of sorted) {
|
||||
if (v.detect !== undefined && v.detect(path, head)) return v
|
||||
)) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// Pass 2: extension match ([] is catch-all, skipped here).
|
||||
for (const v of sorted) {
|
||||
if (v.exts.length === 0) continue
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
// Pass 3: catch-all (exts === []).
|
||||
for (const v of sorted) {
|
||||
if (v.exts.length === 0) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
@@ -230,7 +254,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const tab: SidebarTab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title,
|
||||
// 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 } : {}),
|
||||
}
|
||||
@@ -260,29 +286,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab with the same dedupeKey exists, focus it instead.
|
||||
* Mirrors the three branches the old `openTab` hardcoded (single/editor/diff).
|
||||
* 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 key = descriptor.dedupeKey?.(tab)
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key)
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||
targetId = firstLeaf(state.splits).id
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
splits: mapLeaf(state.splits, targetId, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). */
|
||||
|
||||
@@ -640,6 +640,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 {
|
||||
|
||||
+21
-20
@@ -12,18 +12,13 @@
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/**
|
||||
* Tab type identifier. Builtins use the strings below; external plugins
|
||||
* register their own ids (e.g. `'my-plugin:db'`) through the sidebar
|
||||
* service. Kept as `string` so the registry is open; {@link SIDEBAR_BUILTIN_TAB_TYPES}
|
||||
* lists the builtins for back-compat checks.
|
||||
* 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
|
||||
|
||||
/** The tab type ids the plugin itself registers (see {@link ./builtins.ts}). */
|
||||
export const SIDEBAR_BUILTIN_TAB_TYPES = [
|
||||
'explorer', 'git', 'editor', 'terminal', 'subagent', 'diff',
|
||||
] as readonly string[]
|
||||
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
@@ -75,7 +70,6 @@ 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
|
||||
|
||||
let nextIdCounter = 0
|
||||
/** Unique pane/tab id within one state instance. */
|
||||
@@ -310,14 +304,15 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab (or focus its existing instance by id). Dedup strategies
|
||||
* (single-instance, per-path, per-change) are owned by the tab descriptor
|
||||
* through {@link BetterSidebarService.openTab} / `dedupeKey`; this reducer
|
||||
* only handles the id-based safety net (reconcile/openDiffTab already
|
||||
* check existence before calling) and lands the tab in the active pane
|
||||
* (or the first pane when the active one is gone).
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
|
||||
* this reducer only handles the id-based safety net (reconcile and
|
||||
* openDiffTab already check existence before calling) and the landing
|
||||
* itself — the service's dedupe path delegates here after its dedupeKey
|
||||
* check misses.
|
||||
*/
|
||||
export function 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.
|
||||
@@ -373,6 +368,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 {
|
||||
@@ -393,7 +394,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 }
|
||||
@@ -488,7 +489,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 = {
|
||||
@@ -496,7 +497,7 @@ export function reconcileAgentTerminals(
|
||||
type: 'terminal',
|
||||
title: terminal.title,
|
||||
}
|
||||
next = openTab(next, tab)
|
||||
next = openTabInActivePane(next, tab)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
+19
-5
@@ -123,8 +123,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)
|
||||
})
|
||||
@@ -141,7 +152,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()
|
||||
}
|
||||
@@ -194,8 +208,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) => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Browser globals for specs that pull browser-bound modules. Import this
|
||||
* FIRST in a spec file: unlike statements after imports (which vitest
|
||||
* hoists above the module body), an import statement evaluates in order,
|
||||
* so this runs before any later import's module graph (xterm's UMD wrapper
|
||||
* checks `self` and its color defaults probe `document` at evaluation time;
|
||||
* window/localStorage are only touched at runtime by the store).
|
||||
*/
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
if (g.self === undefined) g.self = globalThis
|
||||
|
||||
const elementStub = (): Record<string, unknown> => ({
|
||||
style: {},
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
setAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
|
||||
// xterm's color palette probe measures a canvas context at module load.
|
||||
getContext: () => null,
|
||||
})
|
||||
|
||||
if (g.document === undefined) {
|
||||
g.document = {
|
||||
createElement: () => elementStub(),
|
||||
createDocumentFragment: () => elementStub(),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
defaultView: globalThis,
|
||||
body: elementStub(),
|
||||
// CodeMirror's view module probes the UA and injects styles at load.
|
||||
documentElement: elementStub(),
|
||||
head: elementStub(),
|
||||
styleSheets: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (g.navigator === undefined) {
|
||||
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
|
||||
}
|
||||
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 8 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
|
||||
* viewer pin the registry's matching behavior.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerBuiltins({} as Context, service)
|
||||
return { service, dispose }
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
|
||||
})
|
||||
|
||||
it('single-instance tabs use the single sugar', () => {
|
||||
const { service } = setup()
|
||||
for (const id of ['explorer', 'git', 'subagent']) {
|
||||
expect(service.getTab(id)?.single).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 8 built-in file viewers', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
)
|
||||
})
|
||||
|
||||
it('code is the catch-all at the lowest priority', () => {
|
||||
const { service } = setup()
|
||||
const code = service.getFileViewers().find(v => v.id === 'code')
|
||||
expect(code?.exts).toEqual([])
|
||||
expect(code?.priority).toBe(-100)
|
||||
expect(code?.fetchStrategy).toBe('fsRead')
|
||||
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('markdown claims md/markdown before the catch-all', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
|
||||
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||||
})
|
||||
|
||||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||||
const { service } = setup()
|
||||
// First match (no head) falls to the catch-all code viewer...
|
||||
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
|
||||
// ...but the head re-match (NUL probe) routes it to binary-download.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
|
||||
// A NUL-free blob stays with code.
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
|
||||
const { service } = setup()
|
||||
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in disposer', () => {
|
||||
it('unregisters everything (HMR-safe)', () => {
|
||||
const { service, dispose } = setup()
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
// The disposer is idempotent.
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the editor-load planning: the pure strategy dispatch the
|
||||
* editor host runs (planFirstMatch / planFsReadOutcome / decodeHead).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts'
|
||||
import type { FileViewerDescriptor } from '../src/client/service.ts'
|
||||
|
||||
const viewer = (over: Partial<FileViewerDescriptor>): FileViewerDescriptor => ({
|
||||
id: 'v',
|
||||
exts: [],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: () => null,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('planFirstMatch', () => {
|
||||
it('no viewer → binary (download UI)', () => {
|
||||
expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary-download strategy → binary', () => {
|
||||
const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('mediaUrl / none → render with the media URL', () => {
|
||||
const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' })
|
||||
expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' })
|
||||
expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render')
|
||||
})
|
||||
|
||||
it('custom → customLoad (the viewer loads its own bytes)', () => {
|
||||
const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v })
|
||||
})
|
||||
|
||||
it('fsRead → fetchFsRead', () => {
|
||||
const v = viewer({ id: 'code', fetchStrategy: 'fsRead' })
|
||||
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v })
|
||||
})
|
||||
})
|
||||
|
||||
describe('planFsReadOutcome', () => {
|
||||
const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
// The builtin binary-download NUL probe shape.
|
||||
if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' })
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('text result renders with content, same viewer', () => {
|
||||
const v = viewer({ id: 'code' })
|
||||
const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m')
|
||||
expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true })
|
||||
})
|
||||
|
||||
it('binary without head bytes → binary', () => {
|
||||
expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary whose NUL head re-matches nothing renderable → binary', () => {
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes
|
||||
rematch,
|
||||
() => '/m',
|
||||
)
|
||||
// binary-download claims it — still the download UI.
|
||||
expect(action).toEqual({ kind: 'binary' })
|
||||
})
|
||||
|
||||
it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => {
|
||||
const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => sniffer,
|
||||
() => '/m',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'customLoad', viewer: sniffer })
|
||||
})
|
||||
|
||||
it('binary re-matched to a mediaUrl viewer → render with the media URL', () => {
|
||||
const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' })
|
||||
const action = planFsReadOutcome(
|
||||
viewer({}),
|
||||
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
|
||||
() => media,
|
||||
() => '/m?x=1',
|
||||
)
|
||||
expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeHead', () => {
|
||||
it('decodes base64 to the exact bytes', () => {
|
||||
const head = decodeHead('AP//AA==')
|
||||
expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+164
-10
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
|
||||
}
|
||||
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves } from '../src/client/state.ts'
|
||||
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
@@ -104,22 +104,64 @@ describe('matchFileViewer', () => {
|
||||
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
|
||||
})
|
||||
|
||||
it('detect overrides exts when head bytes are available', () => {
|
||||
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: [],
|
||||
exts: ['mag'],
|
||||
priority: 5,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// Without head bytes, by-ext wins (priority 10 > 5, and .bin matches).
|
||||
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
|
||||
// With magic bytes, by-magic wins (detect overrides).
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
|
||||
// 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', () => {
|
||||
@@ -148,7 +190,7 @@ describe('service.openTab dedupe', () => {
|
||||
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('no dedupeKey opens a new tab each time', () => {
|
||||
it('no dedupeKey opens a new tab for each distinct id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
@@ -157,13 +199,29 @@ describe('service.openTab dedupe', () => {
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
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)
|
||||
@@ -186,4 +244,100 @@ describe('service.openTab dedupe', () => {
|
||||
expect(tabs[1]!.id).toBe('counter:2')
|
||||
expect(state.nextTerminal).toBe(3)
|
||||
})
|
||||
|
||||
it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
it('the descriptor title is the default when no title is given', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'plain' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
|
||||
expect(tab?.title).toBe('Plain')
|
||||
})
|
||||
|
||||
it('single: true dedupes like dedupeKey: () => id (sugar)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton' })
|
||||
service.openTab({ type: 'singleton', id: 'singleton:extra' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an explicit dedupeKey wins over single: true', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
single: true,
|
||||
dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
service.openTab({ type: 'multi', id: 'multi:2' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs).toHaveLength(2)
|
||||
service.openTab({ type: 'multi', id: 'multi:1' })
|
||||
const state2 = store.getSnapshot().state!
|
||||
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
|
||||
expect(tabs2).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('available receives ctx, scope and the live state (superset signature)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: unknown[] = []
|
||||
service.registerTab({
|
||||
id: 'gated',
|
||||
title: 'Gated',
|
||||
available: (ctx, scope, state) => {
|
||||
seen.push([ctx, scope, state])
|
||||
return true
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true)
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'diff',
|
||||
title: 'Diff',
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false }
|
||||
const paneId = store.getSnapshot().state!.activePane!
|
||||
// The git view's placement path (split surgery) opens the diff tab...
|
||||
store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed }))
|
||||
// ...and a service open of the same change focuses the existing tab
|
||||
// (the descriptor's dedupeKey is the same per-change id rule).
|
||||
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+19
-19
@@ -4,7 +4,7 @@ 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,
|
||||
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
@@ -217,22 +217,22 @@ describe('sidebar state', () => {
|
||||
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 with the SAME id focuses the existing tab instead of duplicating.
|
||||
const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||
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 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' })
|
||||
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
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' })
|
||||
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'])
|
||||
})
|
||||
|
||||
@@ -246,7 +246,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')
|
||||
@@ -262,7 +262,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'))
|
||||
@@ -276,7 +276,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'))
|
||||
@@ -341,10 +341,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)
|
||||
@@ -375,7 +375,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 仍存在。
|
||||
@@ -401,7 +401,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')
|
||||
@@ -419,7 +419,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')
|
||||
@@ -468,7 +468,7 @@ 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)
|
||||
})
|
||||
})
|
||||
@@ -511,7 +511,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)
|
||||
@@ -688,7 +688,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)
|
||||
})
|
||||
@@ -698,13 +698,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)
|
||||
|
||||
Reference in New Issue
Block a user