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:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Public API surface guard (v0.12.0+):
|
||||
* - The CLIENT-REACHABLE source graph stays free of Node.js types: the
|
||||
* shipped `Context` (TabComponentProps / the cordis augmentation) must
|
||||
* not leak `node:*` imports or the `Buffer` global into browser-only
|
||||
* consumer builds. context-types.ts is the shared file — a regression
|
||||
* here breaks external consumers with `skipLibCheck: false`.
|
||||
* - The version/features constants exist and are consistent (the detailed
|
||||
* package.json lockstep assertion lives in service.spec.ts).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The client-reachable source files (the declaration graph consumers pull). */
|
||||
function clientGraphFiles(): string[] {
|
||||
const files: string[] = []
|
||||
const walk = (dir: string): void => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name !== 'chunks') walk(path)
|
||||
} else if (/\.(ts|tsx)$/.test(name)) {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(join(ROOT, 'src', 'client'))
|
||||
for (const extra of ['src/context-types.ts', 'src/html-route.ts', 'src/prefs-shared.ts']) {
|
||||
files.push(join(ROOT, extra))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
describe('public API surface (v0.12.0)', () => {
|
||||
it('the client-reachable graph imports no node:* modules', () => {
|
||||
const offenders = clientGraphFiles().filter(path => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return /from\s+['"]node:/.test(text)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('the client-reachable graph never references the Buffer global', () => {
|
||||
const offenders = clientGraphFiles().filter(path => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return text.split('\n').some(line =>
|
||||
/\bBuffer\b/.test(line)
|
||||
&& !/^\s*\*/.test(line) // doc comments
|
||||
&& !/^\s*\/\//.test(line) // line comments
|
||||
&& !/\/\*/.test(line), // block comment openers
|
||||
)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('advertises the capability list and version (values come from service.ts)', () => {
|
||||
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
expect(SIDEBAR_FEATURES.length).toBeGreaterThanOrEqual(8)
|
||||
for (const feature of ['badge', 'tabLifecycle', 'updateTab', 'openFile', 'targetedOpen', 'stateSubscription', 'tabMeta', 'pluginSettings']) {
|
||||
expect(SIDEBAR_FEATURES).toContain(feature)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Consumer-facing type surface compile gate (v0.12.0+): this file exercises
|
||||
* EVERY public type and descriptor field exactly the way an external plugin
|
||||
* would, so `pnpm typecheck` fails the moment the shipped declaration
|
||||
* surface (service.ts re-exports / descriptor fields / service methods)
|
||||
* drifts from what consumers can name. Type-only — erased at runtime, never
|
||||
* executed by vitest (no `*.spec` suffix).
|
||||
*
|
||||
* Mirrors the "external consumer" fixture: what is importable here from
|
||||
* `../src/client/service.ts` must also be importable from
|
||||
* `dsh-better-sidebar/client/service` in the built package.
|
||||
*/
|
||||
import type {} from '../src/client/service.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileFetchStrategy,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
OpenTabSeed,
|
||||
SidebarSettingsDeclaration,
|
||||
SidebarSettingsRenderProps,
|
||||
SidebarSettingToggle,
|
||||
SidebarSettingToggleType,
|
||||
TabComponentProps,
|
||||
TabDescriptor,
|
||||
} from '../src/client/service.ts'
|
||||
import type {
|
||||
SessionScope,
|
||||
SidebarDiffRef,
|
||||
SidebarPrefs,
|
||||
SidebarSnapshot,
|
||||
SidebarState,
|
||||
SidebarStore,
|
||||
SidebarTab,
|
||||
TabType,
|
||||
} from '../src/client/service.ts'
|
||||
|
||||
/** A full-featured external tab descriptor using every v0.12.0 field. */
|
||||
const tab: TabDescriptor = {
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: (size: number) => null,
|
||||
order: 50,
|
||||
hidden: false,
|
||||
available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null,
|
||||
single: false,
|
||||
dedupeKey: (t: SidebarTab) => t.id,
|
||||
createTab: (state: SidebarState) => ({
|
||||
tab: { id: `my-plugin:db:${state.nextTerminal}`, type: 'my-plugin:db', title: 'DB', meta: { n: state.nextTerminal } },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}),
|
||||
badge: (ctx, scope, state) => (state.expanded.length > 0 ? state.expanded.length : null),
|
||||
onOpen: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
onActivate: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
onClose: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
settings: {
|
||||
toggles: [{ key: 'autoOpenSubagent', title: 'Auto-open', type: 'switch' }],
|
||||
pluginToggles: [{
|
||||
key: 'pageSize',
|
||||
title: 'Page size',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
unit: 'rows',
|
||||
}],
|
||||
render: (props: SidebarSettingsRenderProps) => {
|
||||
props.updatePluginSetting('refresh', true)
|
||||
props.close()
|
||||
return null
|
||||
},
|
||||
},
|
||||
component: (props: TabComponentProps) => {
|
||||
const { ctx, store, scope, tab, visible } = props
|
||||
void ctx; void store; void scope; void tab; void visible
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
/** A full-featured external viewer using the v0.12.0 load signal. */
|
||||
const viewer: FileViewerDescriptor = {
|
||||
id: 'my-plugin:csv',
|
||||
title: 'CSV',
|
||||
exts: ['csv'],
|
||||
priority: 10,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head: Uint8Array) => head.length > 0,
|
||||
load: async (path: string, scope: SessionScope, signal?: AbortSignal) => {
|
||||
void path; void scope; void signal
|
||||
return { rows: [] }
|
||||
},
|
||||
settings: { pluginToggles: [{ key: 'delimiter', title: 'Delimiter', type: 'text', placeholder: ',' }] },
|
||||
component: (props: FileViewerProps) => {
|
||||
const { customData, content, truncated, mediaUrl, viewerId, path, title } = props
|
||||
void customData; void content; void truncated; void mediaUrl; void viewerId; void path; void title
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
/** The full service surface, exercised exactly as consumers call it. */
|
||||
declare const ctx: { betterSidebar: BetterSidebarService }
|
||||
const service: BetterSidebarService = ctx.betterSidebar
|
||||
service.registerTab(tab)
|
||||
service.registerFileViewer(viewer)
|
||||
service.getTabs()
|
||||
service.getFileViewers()
|
||||
service.getTab('my-plugin:db')
|
||||
service.isTabEnabled('my-plugin:db')
|
||||
service.isViewerEnabled('my-plugin:csv')
|
||||
service.matchFileViewer('a.csv', new Uint8Array([1]))
|
||||
service.closeTab('tab:1')
|
||||
service.subscribe(() => {})
|
||||
const seed: OpenTabSeed = { type: 'my-plugin:db', title: 'DB', path: '/p', id: 'x', meta: { a: 1 } }
|
||||
service.openTab(seed)
|
||||
service.openTab(seed, { sessionId: 's1', cwd: '/p' })
|
||||
service.version
|
||||
service.features.includes('badge')
|
||||
const snapshot: SidebarSnapshot | undefined = service.getSnapshot()
|
||||
void snapshot
|
||||
service.subscribeState(() => {})
|
||||
service.updateTab('tab:1', { title: 'T', path: '/p', meta: 1 })
|
||||
service.activateTab('tab:1')
|
||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
|
||||
|
||||
/** Named state vocabulary stays importable (the pre-0.12 gap). */
|
||||
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
|
||||
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
|
||||
const store: SidebarStore = null as unknown as SidebarStore
|
||||
const toggleType: SidebarSettingToggleType = 'number'
|
||||
const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType }
|
||||
const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] }
|
||||
const typeName: TabType = 'my-plugin:db'
|
||||
const strategy: FileFetchStrategy = 'mediaUrl'
|
||||
void diff; void prefs; void store; void declaration; void typeName; void strategy
|
||||
@@ -63,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+254
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -527,6 +527,8 @@ describe('side card settings routes', () => {
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
// The plugin-owned settings map defaults to {} too.
|
||||
pluginSettings: {},
|
||||
},
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
+69
-5
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
@@ -1169,6 +1169,7 @@ describe('side card preferences', () => {
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1190,6 +1191,7 @@ describe('side card preferences', () => {
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1211,6 +1213,7 @@ describe('side card preferences', () => {
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
})
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
@@ -1271,9 +1274,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -1309,7 +1312,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -1317,7 +1320,7 @@ describe('side card preferences', () => {
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
@@ -1647,3 +1650,64 @@ describe('open-path interception wiring', () => {
|
||||
expect(ctx.workspaces.openPath).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('v0.12.0 store additions', () => {
|
||||
// These blocks exercise store reduce/reduceFor (which schedule the
|
||||
// localStorage persist through window timers) and sanitizeState (which
|
||||
// reads window.innerHeight). Stub the browser globals ONLY inside this
|
||||
// scope so the earlier describes keep their window-less environment.
|
||||
beforeEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
|
||||
g.localStorage = { getItem: () => null, setItem: () => {} }
|
||||
})
|
||||
afterEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.localStorage
|
||||
})
|
||||
|
||||
describe('store.reduceFor (targeted opens, v0.12.0)', () => {
|
||||
it('mutates the target session, persists it, and leaves the active snapshot untouched', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
let calls = 0
|
||||
store.subscribe(() => { calls++ })
|
||||
store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] }))
|
||||
// No notify, no snapshot switch.
|
||||
expect(calls).toBe(0)
|
||||
expect(store.getSnapshot().sessionId).toBe('s1')
|
||||
// The target session's state updated and loads back on switch.
|
||||
store.setSession('s2')
|
||||
expect(store.getSnapshot().state?.expanded).toEqual(['/x'])
|
||||
})
|
||||
|
||||
it('loads a fresh state for a never-visited target session', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false }))
|
||||
store.setSession('brand-new')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
expect(store.getSnapshot().state?.splits).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab meta persistence (v0.12.0)', () => {
|
||||
it('sanitizeState carries plugin meta through a reload round-trip', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({
|
||||
...s,
|
||||
splits: {
|
||||
kind: 'leaf' as const,
|
||||
id: 'pane:1',
|
||||
tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }],
|
||||
active: 'tab:1',
|
||||
},
|
||||
}))
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
|
||||
const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user