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:
@@ -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: () => {},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user