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
+155 -8
View File
@@ -62,6 +62,7 @@ import type { SidebarStore } from './state.ts'
import type {
BetterSidebarService,
FileViewerDescriptor,
SidebarSettingsRenderProps,
SidebarSettingToggle,
TabDescriptor,
} from './service.ts'
@@ -112,6 +113,43 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true
}
/** Whether a feature declares any secondary settings (gear button shows). */
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
const settings = feature.settings
return settings !== undefined && (
(settings.toggles?.length ?? 0) > 0
|| (settings.pluginToggles?.length ?? 0) > 0
|| settings.render !== undefined
)
}
/** A feature's display name (viewers fall back to their id). */
function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
return textOf('title' in feature ? feature.title : undefined) || feature.id
}
/**
* Render a custom settings panel (`settings.render`) with error containment:
* a throwing panel shows an inline error line instead of breaking the whole
* settings page.
*/
function SettingsRender(props: {
render: (renderProps: SidebarSettingsRenderProps) => ReactNode
renderProps: SidebarSettingsRenderProps
}) {
let content: ReactNode
try {
content = props.render(props.renderProps)
} catch (error) {
content = (
<div className={css.error} role="alert">
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
</div>
)
}
return <>{content}</>
}
/**
* The custom switch: a real checkbox (hidden, native semantics and focus)
* driving a styled track/thumb. Used by the general toggle rows and the
@@ -240,6 +278,72 @@ function TypedRow(props: {
)
}
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
* shared store/service, the live prefs, the descriptor's own plugin
* settings blob, a persistence helper, and a close callback;
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
* projected onto the prefs face so the shared row renderer reads them).
*/
export function SettingsBody(props: {
feature: TabDescriptor | FileViewerDescriptor
prefs: SidebarPrefs
store: SidebarStore
service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginWrite: (key: string, value: unknown) => void
onClose: () => void
}) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)
}
const toggles = feature.settings?.toggles ?? []
const pluginToggles = feature.settings?.pluginToggles ?? []
if (toggles.length === 0 && pluginToggles.length === 0) return null
// Plugin rows read/write their values through a projected prefs face so
// the shared row renderer works unchanged (its rows read prefs[key]).
const effectivePrefs = { ...prefs, ...(prefs.pluginSettings[feature.id] ?? {}) } as SidebarPrefs
return (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
/>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={effectivePrefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
/>
)}
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service).
@@ -250,7 +354,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
@@ -368,6 +472,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return raw
}
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
applyPref({
pluginSettings: {
...prefs.pluginSettings,
[descriptorId]: { ...(prefs.pluginSettings[descriptorId] ?? {}), [key]: value },
},
})
}
/** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
applyPluginSetting(descriptorId, toggle.key, next)
}
/** Commit one plugin-owned text/number row (clamped like the host rows). */
const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
if (toggle.type === 'number') {
const parsed = Number(raw)
const blob = prefs.pluginSettings[descriptorId] ?? {}
const fallback = String(blob[toggle.key] ?? '')
if (!Number.isFinite(parsed)) return fallback
let clamped = Math.round(parsed)
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
applyPluginSetting(descriptorId, toggle.key, clamped)
return String(clamped)
}
applyPluginSetting(descriptorId, toggle.key, raw)
return raw
}
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
@@ -511,8 +647,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
&& (tab.settings?.toggles?.length ?? 0) > 0
onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab)
? () => { setSettingsFor(tab) }
: undefined,
})}
@@ -536,6 +671,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
? () => { setSettingsFor(viewer) }
: undefined,
})}
</Fragment>
))}
@@ -547,13 +685,16 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
Done footer (Modal chrome is the app's own). Mounted only while a
feature is open — the Modal primitive runs hooks unconditionally,
so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split. */}
renderToString spec) under the test dual-react split.
Content: `settings.render` (custom panel) when declared, else the
host-prefs `toggles` rows followed by the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]). */}
{settingsFor !== null && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })}
title={featureNameOf(settingsFor)}
description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
closeLabel={t('close')}
className={css.popupDialog}
footer={(
@@ -562,11 +703,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</button>
)}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
<SettingsBody
feature={settingsFor}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }}
store={store}
service={service}
/>
</Modal>
)}