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/builtins.tsx b/src/client/builtins.tsx
deleted file mode 100644
index 229bb68..0000000
--- a/src/client/builtins.tsx
+++ /dev/null
@@ -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) => ,
- 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
- :
- ),
- },
- ]
-}
-
-/** 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 }) => (
-
-

-
- ),
- },
- {
- 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 }) => ,
- },
-]
-
-/**
- * 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/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 }) => (
+
+

+
+ ),
+ },
+ {
+ 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/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/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/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/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 50abd68..649cd05 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', () => {
@@ -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)
+ })
})