diff --git a/AGENTS.md b/AGENTS.md index e740899..9ca4918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: , order: 50, - dedupeKey: () => 'my-plugin:notes', // 单实例 + single: true, // ≡ dedupeKey: () => 'my-plugin:notes' component: ({ scope }) => , }) ) @@ -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 的真实用法。 diff --git a/README.md b/README.md index af21455..653802b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/plans/2026-08-11-service-registry-design.md b/docs/plans/2026-08-11-service-registry-design.md index 14e811a..58de763 100644 --- a/docs/plans/2026-08-11-service-registry-design.md +++ b/docs/plans/2026-08-11-service-registry-design.md @@ -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 语义重写。 diff --git a/package.json b/package.json index c4e76db..f31927c 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx new file mode 100644 index 0000000..6e1c202 --- /dev/null +++ b/src/client/EditorHost.tsx @@ -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({ 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 ( +
+
+ {title} +
+ {load.status === 'loading' &&
{t('loading')}
} + {load.status === 'error' &&
{load.message}
} + {load.status === 'binary' && } + {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, + })} +
+ ) +} diff --git a/src/client/OrphanedTab.tsx b/src/client/OrphanedTab.tsx index c62ce8b..2884a4d 100644 --- a/src/client/OrphanedTab.tsx +++ b/src/client/OrphanedTab.tsx @@ -20,7 +20,7 @@ export function OrphanedTab(props: TabComponentProps): ReactNode {
{t('pluginNotLoaded')} - {tab.type} + {tab.type}
) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 39b0356..d72b3af 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 }) {
({ status: 'loading' }) +export function TextEditor(props: FileViewerProps) { + const { scope, path, viewerId, content, truncated } = props const [mode, setMode] = useState('preview') /** The editor's current text (null while clean); preview renders this. */ const [draft, setDraft] = useState(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 ( -
+ <>
- {title} - {previewable && ( + {markdown && (
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} - {load.status === 'binary' && ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- )} {editable && ( <> - {load.truncated && mode === 'edit' &&
{t('truncation')}
} + {truncated === true && mode === 'edit' &&
{t('truncation')}
}
)} - {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' && (
- +
)} -
+ ) } - -/** 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 -} diff --git a/src/client/api.ts b/src/client/api.ts index 5583565..8fd943c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response diff --git a/src/client/binary-download.tsx b/src/client/binary-download.tsx new file mode 100644 index 0000000..71ea3ef --- /dev/null +++ b/src/client/binary-download.tsx @@ -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 ( +
+ {t('binaryNoPreview')} + + {t('downloadToView')} + +
+ ) +} diff --git a/src/client/builtins.tsx b/src/client/builtins.tsx deleted file mode 100644 index 52b71e6..0000000 --- a/src/client/builtins.tsx +++ /dev/null @@ -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) => , - order: -1, - hidden: true, - dedupeKey: (tab) => tab.path, - component: ({ ctx, store, scope, tab }) => ( - - ), - }, - { - id: 'explorer', - title: () => t('explorer'), - icon: (size: number) => , - order: 10, - dedupeKey: () => 'explorer', - component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( - { /* no-op */ })} - onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} - onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} - /> - ), - }, - { - id: 'git', - title: () => t('git'), - icon: (size: number) => , - order: 20, - dedupeKey: () => 'git', - component: ({ ctx, store, scope, onOpenDiff }) => ( - { openSidebarFile(ctx, store, scope.sessionId, path) }} - onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })} - /> - ), - }, - { - id: 'subagent', - title: () => t('subagent'), - icon: (size: number) => , - order: 30, - dedupeKey: () => 'subagent', - component: ({ ctx, scope, visible, onSubagentJump }) => ( - { onSubagentJump?.(address.childSessionId) }} - /> - ), - }, - { - id: 'terminal', - title: () => t('terminal'), - icon: (size: number) => , - 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 }) => ( - - ), - }, - { - id: 'diff', - title: () => t('git'), - icon: (size: number) => , - order: -1, - hidden: true, - dedupeKey: (tab) => tab.id, - component: ({ scope, tab }) => ( - tab.diff === undefined ? null - : - ), - }, - ] -} - -/** 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 }) => ( -
- {title} -
- ), - }, - { - id: 'pdf', - exts: ['pdf'], - fetchStrategy: 'mediaUrl', - component: ({ scope, path, title }) => ( - - ), - }, - { - id: 'docx', - exts: ['docx'], - fetchStrategy: 'mediaUrl', - component: ({ scope, path, title }) => ( - - ), - }, - { - id: 'xlsx', - exts: ['xlsx'], - fetchStrategy: 'mediaUrl', - component: ({ scope, path, title }) => ( - - ), - }, - { - id: 'pptx', - exts: ['pptx'], - fetchStrategy: 'mediaUrl', - component: ({ scope, path, title }) => ( - - ), - }, - { - id: 'binary-download', - exts: ['doc', 'xls', 'ppt'], - priority: -50, - fetchStrategy: 'binary-download', - component: ({ scope, path }) => ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- ), - }, -] - -/** - * 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 */ } - } - } -} diff --git a/src/client/builtins/index.ts b/src/client/builtins/index.ts new file mode 100644 index 0000000..bad5352 --- /dev/null +++ b/src/client/builtins/index.ts @@ -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 */ } + } + } +} diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx new file mode 100644 index 0000000..874ac4d --- /dev/null +++ b/src/client/builtins/tabs.tsx @@ -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:` 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) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.path, + component: ({ ctx, store, scope, tab }) => ( + + ), + }, + { + id: 'explorer', + title: () => t('explorer'), + icon: (size: number) => , + order: 10, + single: true, + component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( + { /* no-op */ })} + onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} + onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'git', + title: () => t('git'), + icon: (size: number) => , + order: 20, + single: true, + component: ({ ctx, store, scope, onOpenDiff }) => ( + { openSidebarFile(ctx, store, scope.sessionId, path) }} + onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'subagent', + title: () => t('subagent'), + icon: (size: number) => , + order: 30, + single: true, + component: ({ ctx, scope, visible, onSubagentJump }) => ( + { onSubagentJump?.(address.childSessionId) }} + /> + ), + }, + { + id: 'terminal', + title: () => t('terminal'), + icon: (size: number) => , + 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 }) => ( + + ), + }, + { + id: 'diff', + title: () => t('git'), + icon: (size: number) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.id, + component: ({ scope, tab }) => ( + tab.diff === undefined ? null + : + ), + }, + ] +} diff --git a/src/client/builtins/viewers.tsx b/src/client/builtins/viewers.tsx new file mode 100644 index 0000000..0d1cbcd --- /dev/null +++ b/src/client/builtins/viewers.tsx @@ -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 }) => ( +
+ {title} +
+ ), + }, + { + id: 'pdf', + exts: ['pdf'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'docx', + exts: ['docx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'xlsx', + exts: ['xlsx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'pptx', + exts: ['pptx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'markdown', + exts: ['md', 'markdown'], + fetchStrategy: 'fsRead', + component: (props) => , + }, + { + id: 'code', + exts: [], + priority: -100, + fetchStrategy: 'fsRead', + component: (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 }) => , + }, + ] +} diff --git a/src/client/cm-themes.ts b/src/client/cm-themes.ts index f739179..673c464 100644 --- a/src/client/cm-themes.ts +++ b/src/client/cm-themes.ts @@ -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' diff --git a/src/client/editor-load.ts b/src/client/editor-load.ts new file mode 100644 index 0000000..96249ca --- /dev/null +++ b/src/client/editor-load.ts @@ -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' } +} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index 2e0e30e..b53224e 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -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) => ( ) - -/** 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 - case 'git': return - case 'terminal': return - case 'subagent': return - case 'editor': return - case 'diff': return - default: return null - } -} diff --git a/src/client/index.tsx b/src/client/index.tsx index 9589995..c2d63b1 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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' diff --git a/src/client/office-types.ts b/src/client/office-types.ts index 6440cc3..93a35df 100644 --- a/src/client/office-types.ts +++ b/src/client/office-types.ts @@ -1,6 +1,6 @@ /** * Office file type dispatch — pure functions mapping file extensions to - * preview kinds. Kept separate from {@link EditorView} so the mapping is + * preview kinds. Kept separate from the editor host so the mapping is * unit-testable without mounting React or pulling in docx-preview / Univer. * * The host side never inspects Office extensions: the binary NUL probe in @@ -36,7 +36,7 @@ export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null /** * Map a lowercased extension (with leading dot) to its preview kind. * - * @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`, + * @param ext - the path extension (with leading dot) (`.docx`, * `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none. */ export function officeKindForExt(ext: string): OfficeKind { diff --git a/src/client/office-view.tsx b/src/client/office-view.tsx index 5948cfe..3766aa0 100644 --- a/src/client/office-view.tsx +++ b/src/client/office-view.tsx @@ -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 diff --git a/src/client/service.ts b/src/client/service.ts index cf0401e..24554c0 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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:` ids and bump `nextTerminal`). - * - `matchFileViewer` resolves viewers by priority (desc), then `detect` - * (content sniff), then `exts` (lowercase, no dot). + * - `matchFileViewer` walks descriptors in priority order (desc, stable): + * per descriptor it tries `detect` first (when `head` bytes are given), + * then `exts`; `exts: []` is a catch-all that matches any path. */ import type { ReactNode } from 'react' import type { Context } from '../context-types.ts' import { - activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf, + activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' import type { SessionScope } from './api.ts' @@ -52,14 +54,24 @@ export interface TabDescriptor { order?: number /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */ hidden?: boolean - /** + menu disabled predicate (e.g. terminal at capacity). */ - available?: (state: SidebarState) => boolean + /** + * + menu disabled predicate (e.g. terminal at capacity). Receives the + * session scope and the live sidebar state (counts, expansions). + */ + available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean + /** + * Single-instance sugar: `true` is shorthand for `dedupeKey: () => id` + * (opening the tab focuses an existing one of the same type instead of + * creating a duplicate). An explicit `dedupeKey` always wins when both + * are given. Builtins: explorer/git/subagent use `single: true`. + */ + single?: boolean /** * If provided, opening a tab whose `dedupeKey(tab)` matches an existing * tab's key focuses the existing one instead of creating a new one. * Returning `undefined` means "no dedup — always open a new tab". - * Builtins: explorer/git/subagent use `() => id`; editor uses `tab => tab.path`; - * diff uses `tab => tab.id` (openDiffTab mints change-derived ids). + * Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id` + * (openDiffTab mints change-derived ids). */ dedupeKey?: (tab: SidebarTab) => string | undefined /** @@ -87,6 +99,8 @@ export interface FileViewerProps { scope: SessionScope path: string title: string + /** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */ + viewerId: string /** fsRead text content (fetchStrategy='fsRead'). */ content?: string truncated?: boolean @@ -106,8 +120,8 @@ export interface FileViewerDescriptor { priority?: number fetchStrategy: FileFetchStrategy /** - * Content sniff (overrides exts when `head` bytes are available). - * The first descriptor (priority desc) whose `detect` returns true wins. + * Content sniff: when `head` bytes are available the descriptor's `detect` + * is consulted before its `exts` (per-descriptor, in priority order). */ detect?: (path: string, head: Uint8Array) => boolean /** fetchStrategy='custom' loader. */ @@ -123,10 +137,15 @@ export interface BetterSidebarService { getFileViewers(): readonly FileViewerDescriptor[] /** Find a tab descriptor by id (undefined if not registered). */ getTab(id: string): TabDescriptor | undefined - /** Find a file viewer for a path (priority desc → detect → exts). */ + /** Find a file viewer for a path (priority desc; detect first, then exts). */ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined - /** Open a tab (used by external tabs and the + menu). */ - openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void + /** + * Open a tab (used by external tabs and the + menu). `title` overrides + * the descriptor's title when given (the editor tab shows the file name); + * when the descriptor provides `createTab` it mints the tab itself and + * `title`/`path`/`id` are ignored. + */ + openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void /** Close a tab by id. */ closeTab(tabId: string): void /** Subscribe to registry changes (register/dispose). */ @@ -194,29 +213,34 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => { const ext = extOfPath(path) - // Priority descending; stable order for equal priorities (insertion order). - const sorted = Array.from(viewers.values()).sort( + // Single pass in priority order (descending; stable for equal + // priorities — insertion order). Each descriptor gets first refusal in + // its own turn: `detect` (when head bytes are available) beats its own + // `exts`, and `exts: []` is a catch-all matching any path — so the + // catch-all `code` viewer (-100) only sees paths no higher-priority + // descriptor claimed. + for (const v of Array.from(viewers.values()).sort( (a, b) => (b.priority ?? 0) - (a.priority ?? 0), - ) - // Pass 1: content sniff (detect overrides exts when head bytes are available). - if (head !== undefined) { - for (const v of sorted) { - if (v.detect !== undefined && v.detect(path, head)) return v + )) { + // Content sniff first (only when head bytes are available). + if (head !== undefined && v.detect !== undefined) { + if (v.detect(path, head)) return v + // A catch-all with detect is SNIFF-ONLY: it must not blind-claim + // paths it never sniffed (a magic-number viewer must not swallow + // every file before the real viewers get their turn). + if (v.exts.length === 0) continue + } else if (v.exts.length === 0) { + // Blind catch-all (no detect) claims anything; a sniff-only + // catch-all (detect defined, no head yet) yields this round. + if (v.detect === undefined) return v + continue } - } - // Pass 2: extension match ([] is catch-all, skipped here). - for (const v of sorted) { - if (v.exts.length === 0) continue if (v.exts.includes(ext)) return v } - // Pass 3: catch-all (exts === []). - for (const v of sorted) { - if (v.exts.length === 0) return v - } return undefined } - const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => { + const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => { store.reduce((state) => { const descriptor = tabs.get(seed.type) if (descriptor === undefined) return state @@ -230,7 +254,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const tab: SidebarTab = { id: seed.id ?? seed.type, type: seed.type, - title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title, + // A caller-provided title wins (the editor shows the file name); + // otherwise the descriptor's (possibly i18n) title is the default. + title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title), ...(seed.path !== undefined ? { path: seed.path } : {}), ...(seed.diff !== undefined ? { diff: seed.diff } : {}), } @@ -260,29 +286,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } /** - * Apply dedup: if a tab with the same dedupeKey exists, focus it instead. - * Mirrors the three branches the old `openTab` hardcoded (single/editor/diff). + * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the + * same type exists, focus it; otherwise land the tab through + * `openTabInActivePane` (the id safety net + active-pane landing are that + * reducer's job — not re-implemented here). + * `single: true` resolves to the id-key sugar when no explicit key is given. */ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { - const key = descriptor.dedupeKey?.(tab) + const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) + const key = dedupeKey?.(tab) if (key !== undefined) { for (const leaf of allLeaves(state.splits)) { - const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key) + const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key) if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } } - let targetId = state.activePane ?? firstLeaf(state.splits).id - if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) { - targetId = firstLeaf(state.splits).id - } - return { - ...state, - activePane: targetId, - splits: mapLeaf(state.splits, targetId, (leaf) => { - leaf.tabs = [...leaf.tabs, tab] - leaf.active = tab.id - }), - } + return openTabInActivePane(state, tab) } /** Find which pane hosts a tab id ('' if none). */ diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 3ed2a10..afd0f01 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 { diff --git a/src/client/state.ts b/src/client/state.ts index c6ce084..93ec300 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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 } diff --git a/src/index.ts b/src/index.ts index 274720c..747455c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,8 +123,19 @@ async function resolveGitPath(cwd: string, raw: string): Promise { 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) => { diff --git a/tests/browser-globals.ts b/tests/browser-globals.ts new file mode 100644 index 0000000..d636a90 --- /dev/null +++ b/tests/browser-globals.ts @@ -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 + +if (g.self === undefined) g.self = globalThis + +const elementStub = (): Record => ({ + 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: () => {}, + } +} diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts new file mode 100644 index 0000000..87c335f --- /dev/null +++ b/tests/builtins.spec.ts @@ -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; 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() + }) +}) diff --git a/tests/editor-load.spec.ts b/tests/editor-load.spec.ts new file mode 100644 index 0000000..c4afb2e --- /dev/null +++ b/tests/editor-load.spec.ts @@ -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 => ({ + 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]) + }) +}) diff --git a/tests/orphaned-tab.spec.tsx b/tests/orphaned-tab.spec.tsx new file mode 100644 index 0000000..64d092f --- /dev/null +++ b/tests/orphaned-tab.spec.tsx @@ -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> } + 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(( + + )) + expect(html).toContain('my-plugin:db') + expect(html).toContain('Database') + expect(html).not.toContain('undefined') + }) +}) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index d929d2f..49999e1 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -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) + }) }) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index c16fdc2..4001499 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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 // 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)