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 }) => ( -
- {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 }) => ( +
+ {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) + }) })