feat: 公共 API 基座化(v0.12.0,纯增量)— 类型/能力/设置 seam 全面补齐

- 类型生态:service 模块 re-export SidebarTab/SidebarState/SidebarStore/
  SidebarSnapshot/SidebarDiffRef/TabType/SessionScope/SidebarPrefs 等公共
  状态词汇(此前 TS2459 无法命名);context-types 去 Node 依赖(结构镜像
  SidebarHttpRequest/Response/UpgradeSocket/Head),client 可达声明图零
  node:* 引用,外部消费者无 @types/node + skipLibCheck:false 可编译
  (scripts/check-consumer-types.sh 守护)
- 服务能力:version/features 版本与能力探测(单调只增)、getSnapshot/
  subscribeState 状态订阅、updateTab(title/path/meta)、activateTab、
  openFile(路径派生 id 与内置拦截一致)、openTab(seed, scope?) 定向到
  指定 session(store.reduceFor 不扰动 UI)
- TabDescriptor:badge 角标(99+ 封顶,抛错吞掉)、onOpen/onActivate/
  onClose 生命周期回调(service 路径派发,dedupe 聚焦触发 onActivate
  而非 onOpen,抛错不打断流程);SidebarTab.meta JSON 可序列化跨刷新
  持久化(sanitize 宽松保留)
- 设置 seam 开放:PrefsSchema 增 pluginSettings 开放嵌套 map(z.dict),
  settings.pluginToggles(插件自有行,同 switch/text/number 控件)与
  settings.render(自定义面板,含 store/service/prefs/updatePluginSetting/
  close props);viewer 卡片 v0.12.0 起也有齿轮设置弹窗
- FileViewerDescriptor.load 增第三参 AbortSignal(EditorHost 生命周期中止)
- UI 接线:tab 激活/关闭改走 service(跨树激活修复 + 回调触发)、+ 菜单
  传 scope、TabBar badge pill 渲染;active-session scope 走原 reduce 路径
  保证 notify
- 测试 +23:生命周期/定向打开/updateTab/activateTab/openFile/meta 持久化/
  reduceFor/能力常量(与 package.json 锁步)、consumer-types.ts 编译门、
  api-surface.spec.ts(client 图零 node 依赖守护);既有 430 测试全绿
- 文档:AGENTS.md 修 available 三参示例并补全部新 API;external-plugin-
  guide.md 刷新至 v0.12.0(7 tab + 9 viewer、设置 seam 开放、类型导入
  路径建议);README×2 服务化小节同步

未发版 npm(用户决定);分支 feat/plugin-api-v012。
This commit is contained in:
Menghuan1918
2026-08-14 20:44:43 +08:00
parent 288a499c7f
commit 82a69546d2
28 changed files with 1512 additions and 103 deletions
+254 -2
View File
@@ -20,8 +20,8 @@ if (g.localStorage === undefined) {
}
}
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -597,3 +597,255 @@ describe('service.openTab auto-expand for content opens', () => {
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
})
})
describe('version and feature detection (v0.12.0)', () => {
it('reports the plugin version in lockstep with package.json', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = JSON.parse(require('node:fs').readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
expect(SIDEBAR_SERVICE_VERSION).toBe(pkg.version)
expect(createBetterSidebarService(createSidebarStore()).version).toBe(SIDEBAR_SERVICE_VERSION)
})
it('advertises every v0.12.0 capability in the features list', () => {
const service = createBetterSidebarService(createSidebarStore())
for (const feature of SIDEBAR_FEATURES) {
expect(service.features).toContain(feature)
}
})
})
describe('state subscription (v0.12.0)', () => {
it('getSnapshot mirrors the store snapshot (sessionId/state/prefs)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
store.setSession('s1')
const snapshot = service.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(snapshot.state).toBeDefined()
expect(snapshot.prefs.openByDefault).toBe(true)
})
it('subscribeState fires on state changes but NOT on registry changes', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
store.setSession('s1')
let calls = 0
const unsub = service.subscribeState(() => { calls++ })
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(calls).toBe(1)
service.registerTab({ id: 'x', title: 'X', component: () => null })
expect(calls).toBe(1) // registry changes are the registry subscription's job
unsub()
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(calls).toBe(1)
})
})
describe('updateTab (v0.12.0)', () => {
it('patches title / path / meta of an open tab', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'doc', title: 'Doc', component: () => null })
store.setSession('s1')
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1' })
service.updateTab('doc:1', { title: 'Compiling…', meta: { progress: 42 } })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
expect(tab.title).toBe('Compiling…')
expect(tab.meta).toEqual({ progress: 42 })
// A missing tab id is a no-op (does not throw).
service.updateTab('doc:missing', { title: 'X' })
})
})
describe('activateTab (v0.12.0)', () => {
it('activates a tab in either tree and fires onActivate with the session scope', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const seen: Array<{ tab: string; sessionId: string }> = []
service.registerTab({
id: 'git',
title: 'Git',
single: true,
onActivate: (tab, scope) => { seen.push({ tab: tab.id, sessionId: scope.sessionId }) },
component: () => null,
})
store.setSession('s1')
// Land in the bottom tree by switching the active pane.
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'git', title: 'Git' })
const gitTab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
expect(gitTab).toBeDefined()
service.activateTab(gitTab.id)
expect(seen).toEqual([{ tab: gitTab.id, sessionId: 's1' }])
// The active pane followed the tab into the bottom tree.
expect(store.getSnapshot().state!.activePane).toBe(gitTab.id === '' ? null : allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
})
})
describe('targeted openTab (v0.12.0)', () => {
it('lands the open in the target session without switching the UI session', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
// The UI snapshot still shows s1, untouched (its default explorer tab
// is the only one — no notes tab landed there).
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(allLeaves(snapshot.state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
// Switching to s2 reveals the tab.
store.setSession('s2')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs.map(t => t.id)).toContain('notes:1')
})
it('a scope naming the active session behaves exactly like a plain open (notify included)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
let calls = 0
store.subscribe(() => { calls++ })
service.openTab({ type: 'explorer', title: 'Explorer' }, { sessionId: 's1' })
expect(calls).toBe(1)
expect(store.getSnapshot().state!.splits).not.toBe(undefined)
})
it('dedupe runs against the TARGET session (opens there focus an existing tab of that session)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
store.setSession('s2')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
expect(tabs).toHaveLength(1)
})
})
describe('openFile (v0.12.0)', () => {
it('opens the file in the editor tab of the scope session with a basename title', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
store.setSession('s1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
expect(tab?.title).toBe('main.ts')
expect(tab?.path).toBe('/p/src/main.ts')
// Windows separators are handled too.
service.openFile({ sessionId: 's1' }, 'C:\\x\\y\\spec.ts', 'custom title')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
expect(tabs[tabs.length - 1]?.title).toBe('custom title')
expect(tabs[tabs.length - 1]?.path).toBe('C:\\x\\y\\spec.ts')
})
})
describe('tab lifecycle callbacks (v0.12.0)', () => {
/** A descriptor with recording callbacks, plus a count of onOpen/onActivate/onClose. */
const setup = () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const events: string[] = []
service.registerTab({
id: 'life',
title: 'Life',
single: true,
onOpen: () => { events.push('open') },
onActivate: () => { events.push('activate') },
onClose: () => { events.push('close') },
component: () => null,
})
return { store, service, events }
}
it('onOpen fires on creation; a dedupe-focus fires onActivate instead', () => {
const { store, service, events } = setup()
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual(['open'])
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual(['open', 'activate'])
})
it('onClose fires when closeTab closes the tab', () => {
const { store, service, events } = setup()
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
events.length = 0
service.closeTab(tab.id)
expect(events).toEqual(['close'])
// Closing a missing tab fires nothing.
events.length = 0
service.closeTab('nope')
expect(events).toEqual([])
})
it('lifecycle callbacks receive the session scope (cwd included when given)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
let seen: { sessionId: string; cwd?: string } | undefined
service.registerTab({
id: 'scoped',
title: 'Scoped',
onOpen: (_tab, scope) => { seen = scope },
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'scoped', title: 'Scoped' }, { sessionId: 's1', cwd: '/work' })
expect(seen).toEqual({ sessionId: 's1', cwd: '/work' })
})
it('a throwing callback is swallowed and never breaks the open', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'boom',
title: 'Boom',
onOpen: () => { throw new Error('plugin bug') },
onClose: () => { throw new Error('plugin bug') },
component: () => null,
})
store.setSession('s1')
expect(() => service.openTab({ type: 'boom', title: 'Boom' })).not.toThrow()
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
expect(() => service.closeTab(tab.id)).not.toThrow()
})
it('a disabled tab type still refuses to open and fires no callbacks', () => {
const { store, service, events } = setup()
store.setSession('s1')
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { life: false } })
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual([])
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
})
})
describe('tab meta (v0.12.0)', () => {
it('a seed meta rides onto the minted tab and survives a reload round-trip', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'db', title: 'DB', component: () => null })
store.setSession('s1')
service.openTab({ type: 'db', title: 'DB', id: 'db:1', meta: { table: 'users', page: 3 } })
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(tab.meta).toEqual({ table: 'users', page: 3 })
// Reload round-trip: the persisted shape sanitizes back with meta intact.
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
const restored = allLeaves(sanitized!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(restored.meta).toEqual({ table: 'users', page: 3 })
})
it('older persisted tabs (no meta) sanitize unchanged', () => {
const state = makeDefaultState(400, true, true)
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state)))
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
expect(tabs[0]?.meta).toBeUndefined()
})
})