feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer),内置 6 tab + 6 viewer 迁移到 registry,v0.4.0
- 新增 src/client/service.ts: BetterSidebarService 接口 + createBetterSidebarService 工厂
- registerTab/registerFileViewer 返回 disposer(HMR-safe,挂到 cordis fiber)
- matchFileViewer 三段匹配: priority desc → detect(内容嗅探) → exts
- openTab 走 descriptor.dedupeKey 统一去重(替代 isSingle/editor-path/diff-id 三分支)
- createTab 回调支持自定义 tab 创建(terminal 的 nextTerminal 递增)
- 新增 src/client/builtins.tsx: 6 内置 tab + 6 内置 viewer 自我注册(吃狗粮)
- tab: editor(hidden) / explorer / git / subagent / terminal / diff(hidden)
- viewer: image / pdf / docx / xlsx / pptx / binary-download
- code/markdown 保持 EditorView 内联兜底(matchFileViewer 返回 undefined 时走 fsRead+CodeMirror)
- 新增 src/client/OrphanedTab.tsx: 未注册 type 的占位组件(插件未加载时降级)
- 改造 src/client/index.tsx: ctx.provide('betterSidebar') + registerBuiltins
- 改造 src/client/Sidebar.tsx: TabContent/buildNewTabOptions/onNewTab 查表替代 switch
- 改造 src/client/EditorView.tsx: 预览路由查 matchFileViewer 替代 if/else 链
- 改造 src/client/state.ts: TabType 联合字面量→string,sanitizeNode 接受所有 string type
- 改造 src/context-types.ts: declare module 'cordis' 加 betterSidebar 字段
- 改造 src/index.ts: re-export Context + 服务类型(consumer import type 触发 augmentation)
- package.json: 加 ./client/service exports 子路径,version 0.4.0
- tests/service.spec.ts: 12 个新测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
- tests/unit.spec.ts: sanitizeState 测试更新(未知 type 不再被拒绝,渲染 OrphanedTab)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
|
||||
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
|
||||
const g = globalThis as Record<string, unknown>
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
}
|
||||
}
|
||||
if (g.localStorage === undefined) {
|
||||
g.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
const dispose = service.registerTab({
|
||||
id: 'test:tab',
|
||||
title: 'Test',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getTabs()).toHaveLength(1)
|
||||
expect(service.getTab('test:tab')?.id).toBe('test:tab')
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
expect(service.getTab('test:tab')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registerTab throws on duplicate id', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'dup', title: 'A', component: () => null })
|
||||
expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow()
|
||||
})
|
||||
|
||||
it('registerFileViewer adds and dispose removes', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
const dispose = service.registerFileViewer({
|
||||
id: 'csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
component: () => null,
|
||||
})
|
||||
expect(service.getFileViewers()).toHaveLength(1)
|
||||
dispose()
|
||||
expect(service.getFileViewers()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('subscribe fires on register and dispose', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
let calls = 0
|
||||
const unsub = service.subscribe(() => { calls++ })
|
||||
const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null })
|
||||
expect(calls).toBe(1)
|
||||
dispose()
|
||||
expect(calls).toBe(2)
|
||||
unsub()
|
||||
service.registerTab({ id: 'y', title: 'Y', component: () => null })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFileViewer', () => {
|
||||
it('matches by extension', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('photo.JPG')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('higher priority wins on extension conflict', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('advanced')
|
||||
})
|
||||
|
||||
it('catch-all (exts: []) matches anything at lowest priority', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('x.png')?.id).toBe('img')
|
||||
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
|
||||
})
|
||||
|
||||
it('detect overrides exts when head bytes are available', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null })
|
||||
service.registerFileViewer({
|
||||
id: 'by-magic',
|
||||
exts: [],
|
||||
priority: 5,
|
||||
fetchStrategy: 'fsRead',
|
||||
detect: (_path, head) => head[0] === 0x89,
|
||||
component: () => null,
|
||||
})
|
||||
// Without head bytes, by-ext wins (priority 10 > 5, and .bin matches).
|
||||
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
|
||||
// With magic bytes, by-magic wins (detect overrides).
|
||||
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
|
||||
})
|
||||
|
||||
it('returns undefined when no viewer matches and no catch-all', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
|
||||
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab dedupe', () => {
|
||||
it('dedupeKey focuses existing tab instead of duplicating', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'singleton',
|
||||
title: 'Singleton',
|
||||
dedupeKey: () => 'singleton',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
service.openTab({ type: 'singleton', title: 'Singleton' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('no dedupeKey opens a new tab each time', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
service.openTab({ type: 'multi', title: 'Multi' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('createTab mints custom ids and patches state', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'counter',
|
||||
title: 'Counter',
|
||||
createTab: (state) => ({
|
||||
tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}),
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
service.openTab({ type: 'counter', title: 'Counter' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('counter:1')
|
||||
expect(tabs[1]!.id).toBe('counter:2')
|
||||
expect(state.nextTerminal).toBe(3)
|
||||
})
|
||||
})
|
||||
+10
-4
@@ -644,10 +644,16 @@ describe('persisted state sanitization', () => {
|
||||
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
|
||||
expect(sanitizeState(withSplit)).toBeUndefined()
|
||||
// Unknown tab types (older layouts) are rejected.
|
||||
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadTab.splits.tabs[0].type = 'watcher'
|
||||
expect(sanitizeState(withBadTab)).toBeUndefined()
|
||||
// Unknown tab types (external plugins not yet loaded) are accepted —
|
||||
// they render as <OrphanedTab/> at view time and recover if the plugin
|
||||
// loads later. Only diff tabs are dropped (ephemeral).
|
||||
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
|
||||
const externalClean = sanitizeState(withExternalTab)
|
||||
expect(externalClean).toBeDefined()
|
||||
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
|
||||
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
|
||||
}
|
||||
// An active id that no tab carries is rejected.
|
||||
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadActive.splits.active = 'ghost-tab'
|
||||
|
||||
Reference in New Issue
Block a user