refactor(builtins): 目录重组为 builtins/{tabs,viewers,index},清理死代码与内联样式

- 新 src/client/builtins/ 目录:tabs.tsx(6 个 tab descriptor +
  TERMINAL_LIMIT/uiTerminalCount,terminal 配额知识离开 state.ts)、
  viewers.tsx(8 个 viewer descriptor)、index.ts(registerBuiltins 聚合
  注册 + 幂等 disposer);删除旧 builtins.tsx
- icons.tsx:删除零调用者的 tabTypeIcon 及多余 import(图标已归
  descriptor.icon,映射随 tab 类型一起注册化)
- OrphanedTab:内联 style → sidebar.module.css 新增 .orphanedType 类
- 新增 tests/builtins.spec.ts(6 tab + 8 viewer 注册断言、code catch-all、
  binary-download NUL detect、office 优先级、disposer 幂等)
- 新增 tests/orphaned-tab.spec.tsx(sanitize 保留未注册类型 tab、
  OrphanedTab 占位渲染);tests/browser-globals.ts 供拉入 xterm/
  CodeMirror 的 spec 首个 import(模块求值期需要 self/document/navigator)
- service.spec 补 openDiffTab 与 diff dedupeKey 一致性用例
This commit is contained in:
Menghuan1918
2026-08-11 11:22:28 +08:00
parent 0467101faf
commit 1b24bc8ff2
12 changed files with 531 additions and 252 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export function OrphanedTab(props: TabComponentProps): ReactNode {
</div>
<div className={css.editorPlaceholder}>
<span>{t('pluginNotLoaded')}</span>
<code style={{ display: 'block', marginTop: 8, fontSize: 12, opacity: 0.7 }}>{tab.type}</code>
<code className={css.orphanedType}>{tab.type}</code>
</div>
</div>
)
-229
View File
@@ -1,229 +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 / markdown / code /
* binary-download) through the same {@link BetterSidebarService} external
* plugins use — eating its own dogfood. Code/markdown are registered
* viewers like any other (fsRead strategy, {@link TextEditor}); the
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves doc/xls/ppt by extension.
*/
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 { mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { openSidebarFile } from './intercept.tsx'
import { ExplorerView } from './ExplorerView.tsx'
import { EditorHost } from './EditorHost.tsx'
import { TextEditor } from './TextEditor.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 { BinaryDownload } from './binary-download.tsx'
import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx'
import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts'
import css from './sidebar.module.css'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
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. */
function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, onOpenDiff }) => (
<GitView
scope={scope}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
),
},
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
},
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
},
{
id: 'diff',
title: () => t('git'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
component: ({ scope, tab }) => (
tab.diff === undefined ? null
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
),
},
]
}
/** The 8 built-in file viewer descriptors — every preview surface is a registered viewer. */
const builtinViewers: readonly FileViewerDescriptor[] = [
{
id: 'image',
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl: url, title }) => (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={url} alt={title} />
</div>
),
},
{
id: 'pdf',
exts: ['pdf'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PdfView scope={scope} path={path} title={title} />
),
},
{
id: 'docx',
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
},
{
id: 'xlsx',
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
},
{
id: 'pptx',
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
},
{
id: 'markdown',
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'code',
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'binary-download',
exts: ['doc', 'xls', 'ppt'],
priority: -50,
fetchStrategy: 'binary-download',
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
// before the catch-all code viewer on the head re-match.
detect: (_path, head) => head.includes(0),
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
},
]
/**
* 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 */ }
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Built-in registration: the plugin registers its own tab pages and file
* previewers through the same {@link BetterSidebarService} external plugins
* use — eating its own dogfood. The descriptors live next to their feature
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
* HMR-safe).
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
* Register all built-in tabs and viewers with the service. Returns a
* disposer that unregisters everything (cordis auto-invokes it on fiber
* disposal). The `ctx` is threaded into tab descriptors that need it
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
*/
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx)) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
disposers.push(service.registerFileViewer(viewer))
}
return () => {
for (const d of disposers) {
try { d() } catch { /* already disposed */ }
}
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / subagent / editor / diff) through the same
* {@link BetterSidebarService} external plugins use — eating its own
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
* mints `terminal:<n>` ids through `createTab`.
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { TerminalView } from '../TerminalView.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
import type { TabDescriptor } from '../service.ts'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, onOpenDiff }) => (
<GitView
scope={scope}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
),
},
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
},
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
},
{
id: 'diff',
title: () => t('git'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
component: ({ scope, tab }) => (
tab.diff === undefined ? null
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
),
},
]
}
+86
View File
@@ -0,0 +1,86 @@
/**
* The 8 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
* binary-download), exactly like external plugins register theirs. The
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*/
import { DocxView, XlsxView } from '../office-view.tsx'
import { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import css from '../sidebar.module.css'
/** The 8 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
{
id: 'image',
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl: url, title }) => (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={url} alt={title} />
</div>
),
},
{
id: 'pdf',
exts: ['pdf'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PdfView scope={scope} path={path} title={title} />
),
},
{
id: 'docx',
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
},
{
id: 'xlsx',
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
},
{
id: 'pptx',
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
},
{
id: 'markdown',
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'code',
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'binary-download',
exts: ['doc', 'xls', 'ppt'],
priority: -50,
fetchStrategy: 'binary-download',
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
// before the catch-all code viewer on the head re-match.
detect: (_path, head) => head.includes(0),
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
},
]
}
+3 -20
View File
@@ -1,14 +1,10 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and the per-tab-type icon mapping shared by the
* tab strip, the + menu, and the empty-pane cards.
* icon library has none) and a diff glyph. Per-tab icons live on the tab
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
* mapping was registry-ized with the tab types.
*/
import type { ReactNode } from 'react'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TabType } from './state.ts'
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
@@ -30,16 +26,3 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
switch (type) {
case 'explorer': return <IconFolderOpen16 size={size} />
case 'git': return <IconBranchOutline16 size={size} />
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'subagent': return <IconThinkOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
case 'diff': return <IconDiffOutline16 size={size} />
default: return null
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { registerBuiltins } from './builtins.tsx'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { loadPrefs } from './prefs.ts'
+9
View File
@@ -640,6 +640,15 @@
text-align: center;
}
/* The unregistered tab type shown by the orphaned-tab placeholder. */
.orphanedType {
display: block;
margin-top: 8px;
font-size: 12px;
opacity: 0.7;
overflow-wrap: anywhere;
}
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
a centered notice plus a download link — the user always gets the file. */
.editorBinary {
+59
View File
@@ -0,0 +1,59 @@
/**
* Browser globals for specs that pull browser-bound modules. Import this
* FIRST in a spec file: unlike statements after imports (which vitest
* hoists above the module body), an import statement evaluates in order,
* so this runs before any later import's module graph (xterm's UMD wrapper
* checks `self` and its color defaults probe `document` at evaluation time;
* window/localStorage are only touched at runtime by the store).
*/
const g = globalThis as Record<string, unknown>
if (g.self === undefined) g.self = globalThis
const elementStub = (): Record<string, unknown> => ({
style: {},
classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {},
getAttribute: () => null,
appendChild: () => {},
addEventListener: () => {},
removeEventListener: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
// xterm's color palette probe measures a canvas context at module load.
getContext: () => null,
})
if (g.document === undefined) {
g.document = {
createElement: () => elementStub(),
createDocumentFragment: () => elementStub(),
addEventListener: () => {},
removeEventListener: () => {},
defaultView: globalThis,
body: elementStub(),
// CodeMirror's view module probes the UA and injects styles at load.
documentElement: elementStub(),
head: elementStub(),
styleSheets: [],
}
}
if (g.navigator === undefined) {
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
}
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 8 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
* viewer pin the registry's matching behavior.
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
return { service, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
)
})
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
})
describe('built-in file viewer registrations', () => {
it('registers the 8 built-in file viewers', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
)
})
it('code is the catch-all at the lowest priority', () => {
const { service } = setup()
const code = service.getFileViewers().find(v => v.id === 'code')
expect(code?.exts).toEqual([])
expect(code?.priority).toBe(-100)
expect(code?.fetchStrategy).toBe('fsRead')
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
})
it('markdown claims md/markdown before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('binary-download claims legacy office by extension', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
})
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
const { service } = setup()
// First match (no head) falls to the catch-all code viewer...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
// ...but the head re-match (NUL probe) routes it to binary-download.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
// A NUL-free blob stays with code.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
})
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
const { service } = setup()
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
// claims it before binary-download (-50) is consulted.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
})
})
describe('built-in disposer', () => {
it('unregisters everything (HMR-safe)', () => {
const { service, dispose } = setup()
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getFileViewers()).toHaveLength(0)
// The disposer is idempotent.
dispose()
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Orphaned-tab tests: a persisted tab whose type is not registered must
* survive sanitize (title/path kept for recovery) and render the graceful
* placeholder instead of crashing — the degradation path of the open
* tab-type set (a contributing plugin not installed / not yet loaded).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { OrphanedTab } from '../src/client/OrphanedTab.tsx'
import { sanitizeState } from '../src/client/state.ts'
/** Collect every tab type in a sanitized tree (leaf/split recursive). */
function typesOf(node: unknown): string[] {
if (node === null || typeof node !== 'object') return []
const record = node as { kind?: unknown; tabs?: unknown; children?: unknown }
if (record.kind === 'leaf' && Array.isArray(record.tabs)) {
return (record.tabs as Array<{ type: string }>).map(tab => tab.type)
}
if (Array.isArray(record.children)) return record.children.flatMap(typesOf)
return []
}
describe('sanitize keeps unregistered tab types', () => {
it('preserves a persisted tab of an unknown type with its title/path', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [
{ id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' },
{ id: 'tab:2', type: 'explorer', title: 'Explorer' },
],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
// The kept tab keeps its payload so a later registration can reuse it.
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
})
it('an unknown type alone in a pane still sanitizes cleanly', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
})
})
describe('OrphanedTab placeholder', () => {
const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' }
it('renders the unregistered type and keeps the title', () => {
const html = renderToString((
<OrphanedTab
ctx={undefined as never}
store={undefined as never}
scope={{ sessionId: 's1', cwd: '/p' }}
tab={tab}
visible={false}
/>
))
expect(html).toContain('my-plugin:db')
expect(html).toContain('Database')
expect(html).not.toContain('undefined')
})
})
+23 -1
View File
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
}
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore, allLeaves } from '../src/client/state.ts'
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -298,4 +298,26 @@ describe('service.openTab dedupe', () => {
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)
})
})