Merge pull request #4 from dsh-external/feat/modular-registry

refactor: 全插件注册化收尾 — 审查修正 + 8/8 viewer 吃狗粮 + 模块化 builtins/
This commit is contained in:
Atlas Zeng
2026-08-11 11:29:49 +08:00
committed by GitHub
30 changed files with 1248 additions and 491 deletions
+33 -21
View File
@@ -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 的真实用法。
+1 -1
View File
@@ -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 语义重写。
+4
View File
@@ -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"
},
+101
View File
@@ -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>
)
}
+1 -1
View File
@@ -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>
)
+6 -6
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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>
)
}
-214
View File
@@ -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 */ }
}
}
}
+33
View File
@@ -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 */ }
}
}
}
+127
View File
@@ -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} />
),
},
]
}
+86
View File
@@ -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} />,
},
]
}
+1 -1
View File
@@ -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'
+92
View File
@@ -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
View File
@@ -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
}
}
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
}
}
// Pass 2: extension match ([] is catch-all, skipped here).
for (const v of sorted) {
)) {
// 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
if (v.exts.includes(ext)) return v
} 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 3: catch-all (exts === []).
for (const v of sorted) {
if (v.exts.length === 0) return v
if (v.exts.includes(ext)) 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). */
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+59
View File
@@ -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: () => {},
}
}
+102
View File
@@ -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()
})
})
+100
View File
@@ -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])
})
})
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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)