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
+4 -5
View File
@@ -16,8 +16,7 @@ import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Context } from './context-types.ts'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
@@ -68,9 +67,9 @@ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undef
* chunk scripts live in (overridable for tests).
*/
export function createBundleRouteHandler(
fence: (req: IncomingMessage) => boolean,
fence: (req: SidebarHttpRequest) => boolean,
chunkDir: string = LIB_DIR,
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
return async (req, res): Promise<void> => {
if (!fence(req)) {
res.writeHead(403)
@@ -121,7 +120,7 @@ export function createBundleRouteHandler(
}
/** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
return ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/bundle',
+5 -2
View File
@@ -31,6 +31,9 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
useEffect(() => {
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
const controller = new AbortController()
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
@@ -50,7 +53,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
})
return
case 'customLoad':
void action.viewer.load?.(path, scope).then((data) => {
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
@@ -77,7 +80,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
}
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true }
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx])
return (
+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>
)}
+32 -10
View File
@@ -32,7 +32,7 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
@@ -555,7 +555,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose).
ctx.betterSidebar?.closeTab(tabId)
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
@@ -566,13 +568,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
},
activateTab: (paneId, tabId) => {
store.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
// Route through the service: same reducer (finds the pane in EITHER
// tree, sets the active pane) and fires descriptor.onActivate.
ctx.betterSidebar?.activateTab(tabId)
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
@@ -631,7 +629,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
service.openTab({ type: optionId, title })
// The session scope rides along: lifecycle callbacks receive it (and
// the open stays in the current session, as before).
service.openTab({ type: optionId, title }, { sessionId, cwd })
}
/**
@@ -647,6 +647,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* The tab badge from the tab-type registry: a count (99+ capped) or a
* short text pill. A throwing badge is swallowed (no pill) — the tab
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-better-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
return <span className={css.tabBadge}>{text}</span>
}
/**
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
@@ -757,6 +777,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
@@ -836,6 +857,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
+5 -1
View File
@@ -63,9 +63,12 @@ export function TabBar(props: {
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
* resolver returns the rendered pill or null). */
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
@@ -136,6 +139,7 @@ export function TabBar(props: {
}}
>
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
+18
View File
@@ -73,9 +73,27 @@ export function parsePrefs(value: unknown): SidebarPrefs {
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
}
}
/**
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
* value } }`, nested open maps. Any non-object value (or a malformed whole)
* falls back to the empty map — the schema defaults already guard the wire
* shape, this is the client's second line.
*/
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, Record<string, unknown>> = {}
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
out[id] = blob as Record<string, unknown>
}
}
return out
}
/**
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
* survive; a non-object or a non-boolean entry falls back to the empty map /
+268 -12
View File
@@ -22,11 +22,30 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
type SidebarState, type SidebarStore, type SidebarTab,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts'
import type { SidebarPrefs } from '../prefs-shared.ts'
/**
* Public state vocabulary re-exported for consumers (type-only; the values
* stay internal). External plugins name these types in their descriptors —
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
* or `badge: (…, state: SidebarState) => …`.
*/
export type {
SidebarTab,
SidebarState,
SidebarStore,
SidebarSnapshot,
SidebarDiffRef,
TabType,
} from './state.ts'
export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
@@ -56,6 +75,19 @@ export interface SidebarSettingToggle {
unit?: string
}
/** Props of a descriptor's custom settings panel (`settings.render`). */
export interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
pluginSettings: Record<string, unknown>
/** Persist one plugin-owned setting of this descriptor. */
updatePluginSetting(key: string, value: unknown): void
/** Close the settings popup. */
close(): void
}
/** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration {
/**
@@ -66,6 +98,21 @@ export interface SidebarSettingsDeclaration {
* by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
/**
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
* (switch/text/number), but the keys are plugin-local and persisted in
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
* — no host PrefsSchema field needed. Values must be JSON-serializable
* (the row controls produce strings / numbers / booleans).
*/
pluginToggles?: readonly SidebarSettingToggle[]
/**
* Custom settings panel (v0.12.0+): when given, the gear popup renders
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
* the shared store/service, the live prefs, the descriptor's own
* `pluginSettings` blob, and a persistence helper.
*/
render?: (props: SidebarSettingsRenderProps) => ReactNode
}
/** Props every tab component receives (builtins and external alike). */
@@ -129,6 +176,27 @@ export interface TabDescriptor {
* (e.g. the subagent tab's 'autoOpenSubagent').
*/
settings?: SidebarSettingsDeclaration
/**
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
* the icon — a number renders as a count (99+ capped), a string renders
* as-is, null/undefined hides the badge. Called on every tab-bar render,
* so keep it cheap; a throw is swallowed (no badge shown).
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
* when a tab is focused (dedupe focus, id-safety-net focus, or the
* tab-bar activation), `onClose` when a tab is closed through
* `closeTab`. Builtin-only flows that mutate state directly (the diff
* split placement, agent-terminal reconcile) never touch external tabs
* and fire no callbacks. A throwing callback is logged and never breaks
* the open/close/activate flow.
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
component: (props: TabComponentProps) => ReactNode
}
@@ -176,8 +244,9 @@ export interface FileViewerDescriptor {
* is consulted before its `exts` (per-descriptor, in priority order).
*/
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' loader. */
load?: (path: string, scope: SessionScope) => Promise<unknown>
/** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
* teardown / re-match; loaders that ignore it keep working. */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/**
* Declarative settings shown in the Side card settings page: every
* registered viewer gets an enable/disable switch (icon + title + exts).
@@ -186,7 +255,26 @@ export interface FileViewerDescriptor {
component: (props: FileViewerProps) => ReactNode
}
/** The registry service published as `ctx.betterSidebar`. */
/** One `openTab` request. */
export interface OpenTabSeed {
type: string
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
title?: string
/** A file path (the editor tab's content seed). */
path?: string
/** A diff reference (the diff tab's content seed). */
diff?: SidebarTab['diff']
/** Explicit tab id (defaults to the type). */
id?: string
/** A URL the tab navigates to on mount (the browser tab's seed). */
url?: string
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
meta?: unknown
}
/**
* The registry service published as `ctx.betterSidebar`.
*/
export interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void
registerFileViewer(descriptor: FileViewerDescriptor): () => void
@@ -217,18 +305,49 @@ export interface BetterSidebarService {
* usually pairs it with a hostname `title`). A disabled tab type is a
* no-op.
*
* `scope` (v0.12.0+) targets a specific session: when given, the open
* lands in THAT session's sidebar state (loading it if it has none yet)
* without switching the UI's active session; when absent the open lands
* in the currently active session (the pre-0.12 behavior).
*
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
* panel hosting the landing pane is collapsed, it is expanded
* automatically (the right panel by default, the bottom panel when the
* active pane lives there; on narrow viewports the merged drawer opens).
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
* the panel behavior is their caller's business.
*
* Note: `available` gates the + menu's disabled state only — it does NOT
* refuse `openTab` (only the settings disable switch does).
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** Close a tab by id. */
closeTab(tabId: string): void
/** Subscribe to registry changes (register/dispose). */
subscribe(listener: () => void): () => void
/** The plugin version this service instance was built from ('0.12.0'). */
readonly version: string
/**
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
* gate new API usage on membership.
*/
readonly features: readonly string[]
/**
* The current sidebar snapshot: the active session id, its state (panel
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
* `state`/`sessionId` are undefined until a session becomes active.
*/
getSnapshot(): SidebarSnapshot
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
subscribeState(listener: () => void): () => void
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** Activate an open tab (the tab-bar activation path; fires descriptor.onActivate). */
activateTab(tabId: string): void
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** Extract the lowercase extension without leading dot from a path. */
@@ -239,6 +358,50 @@ function extOfPath(path: string): string {
return base.includes('/') || base.includes('\\') ? '' : base
}
/** The file name of a path (both separators). */
function baseNameOf(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.12.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
* are never removed). Each string names a v0.12.0+ capability:
* - 'badge': TabDescriptor.badge
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
* - 'updateTab': BetterSidebarService.updateTab
* - 'openFile': BetterSidebarService.openFile
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
* - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
*/
export const SIDEBAR_FEATURES = [
'badge',
'tabLifecycle',
'updateTab',
'openFile',
'targetedOpen',
'stateSubscription',
'tabMeta',
'pluginSettings',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
function safeCall(fn: () => void): void {
try {
fn()
} catch (error) {
console.error('[dsh-better-sidebar] plugin callback error:', error)
}
}
/**
* Create one BetterSidebar service bound to a store. The service owns the
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
@@ -325,7 +488,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
return undefined
}
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
// A type the user disabled in settings never opens — neither from the
// + menu nor from derived flows (file opens, subagent auto-open,
// external plugins). Already-open tabs keep rendering.
@@ -333,9 +496,18 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
return
}
store.reduce((state) => {
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return state
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return
// A scope targets another session: the open lands in THAT session's
// state (loaded on demand) without switching the UI's active session.
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
if (targetSessionId === undefined) return
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
// Lifecycle capture: `created` when the open minted a NEW tab (a
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
let created: SidebarTab | undefined
let activated: SidebarTab | undefined
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab
let next: SidebarState
@@ -354,6 +526,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
}
next = applyDedupe(state, tab, descriptor)
}
@@ -369,6 +542,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} else {
landed = next
}
// Lifecycle capture (before the auto-expand block, which early-returns).
if (!tabOpenIn(state, tab.id)) {
created = tab
} else {
// A focus happened (dedupeKey or the id safety net): resolve the
// tab that is actually active now and report THAT to onActivate.
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
@@ -395,15 +582,77 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
}
}
return landed
})
}
// A scope targeting ANOTHER session lands the open there without
// switching the UI; a scope naming the active session (or no scope)
// takes the regular reduce path so the UI notifies and re-renders.
const activeSessionId = store.getSnapshot().sessionId
if (scope !== undefined && scope.sessionId !== activeSessionId) {
store.reduceFor(scope.sessionId, reducer)
} else {
store.reduce(reducer)
}
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
}
const closeTab = (tabId: string): void => {
let closed: SidebarTab | undefined
store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
return closeTabReducer(state, paneId, tabId)
})
if (closed !== undefined) {
const sessionId = store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(closed.type)
safeCall(() => descriptor?.onClose?.(closed!, { sessionId }))
}
}
}
/** The snapshot the store publishes (state/prefs carry the active session). */
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
/** Store changes: session switch, state mutations, prefs writes. */
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
/** Patch an open tab's display fields (a missing tab id is a no-op). */
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
store.reduce((state) => patchTab(state, tabId, {
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}))
}
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
const activateTab = (tabId: string): void => {
let activated: SidebarTab | undefined
store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
return activateTabReducer(state, paneId, tabId)
})
if (activated !== undefined) {
const sessionId = store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(activated.type)
safeCall(() => descriptor?.onActivate?.(activated!, { sessionId }))
}
}
}
/** Open a file in the sidebar editor of `scope`'s session (title defaults
* to the file name; the tab id is path-derived, like the internal
* open-path interception, so distinct files open side by side). */
const openFile = (scope: SessionScope, path: string, title?: string): void => {
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
}
return {
@@ -418,6 +667,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
openTab,
closeTab,
subscribe,
version: SIDEBAR_SERVICE_VERSION,
features: SIDEBAR_FEATURES,
getSnapshot,
subscribeState,
updateTab,
activateTab,
openFile,
}
}
@@ -436,7 +692,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
// bottom panel focuses an existing instance wherever it lives.
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
}
return openTabInActivePane(state, tab)
+15
View File
@@ -522,6 +522,21 @@
white-space: nowrap;
}
/* One tab's descriptor badge (a count capped at 99+, or a short text). */
.tabBadge {
flex: none;
min-width: 16px;
height: 15px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-brand-primary);
}
.tabClose {
display: inline-flex;
align-items: center;
+10 -3
View File
@@ -123,8 +123,9 @@ function LeafView(props: {
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
@@ -178,6 +179,7 @@ function LeafView(props: {
onNewTab={onNewTab}
newTabOptions={newTabOptions}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
@@ -217,8 +219,9 @@ function NodeView(props: {
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
if (node.kind === 'leaf') {
return (
<LeafView
@@ -228,6 +231,7 @@ function NodeView(props: {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
)
}
@@ -254,6 +258,7 @@ function NodeView(props: {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
</div>
</Fragment>
@@ -274,8 +279,9 @@ export function Workbench(props: {
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
return (
<div className={css.workbench}>
<NodeView
@@ -286,6 +292,7 @@ export function Workbench(props: {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
</div>
)
+34 -3
View File
@@ -26,13 +26,17 @@ export type SidebarDiffRef =
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
* `diff` carries the change a diff tab shows. */
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
diff?: SidebarDiffRef
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
* persisted with the layout and restored verbatim on reload. */
meta?: unknown
}
/** A tab group. */
@@ -432,14 +436,14 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
}
/** Update the display fields of one open tab (title / path) without
/** Update the display fields of one open tab (title / path / meta) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. The tab may live in either tree. */
export function patchTab(
state: SidebarState,
tabId: string,
patch: { title?: string; path?: string },
patch: { title?: string; path?: string; meta?: unknown },
): SidebarState {
let changed = false
const walk = (node: SplitNode): SplitNode => {
@@ -451,6 +455,7 @@ export function patchTab(
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
})
return tabs === node.tabs ? node : { ...node, tabs }
@@ -886,11 +891,15 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
@@ -1020,6 +1029,28 @@ export class SidebarStore {
this.notify()
}
/**
* Apply a pure reducer to a TARGET session's state (not the active one),
* loading it on demand and persisting the result — WITHOUT switching the
* active snapshot or notifying (the UI must not follow along). Used by the
* service's targeted `openTab(seed, scope)`: the open lands in the target
* session's layout and is visible whenever the user switches to it.
*/
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
this.bySession.set(sessionId, state)
} else {
// Re-seed the uid counter past THIS session's persisted ids, exactly
// like setSession's cache-hit path.
nextIdCounter = maxCounterId(state)
}
const next = reducer(state)
this.bySession.set(sessionId, next)
this.schedulePersist(sessionId, next)
}
private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => {
+5
View File
@@ -100,4 +100,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
// (everything on) with no migration. Non-boolean values fail validation.
tabsEnabled: z.dict(z.boolean()).default({}),
viewersEnabled: z.dict(z.boolean()).default({}),
// Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any
// descriptor id may carry any JSON-serializable values. This is the
// "settings seam" opening — without it the seam would drop third-party
// keys as unknown schema fields.
pluginSettings: z.dict(z.dict(z.any())).default({}),
})
+37 -4
View File
@@ -16,23 +16,56 @@
* - slots: the client runtime SlotRegistry
* - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file.
*
* This file must stay FREE of Node.js types (`node:http`, `node:stream`,
* `Buffer`): it is part of the CLIENT-reachable declaration graph (the
* `Context` in `TabComponentProps` and the `declare module` augmentation),
* so a Node import here would leak into browser-only consumer builds. The
* webServer faces below are therefore structural mirrors with plain
* interfaces (the host casts to real Node types at the few boundaries that
* need them — e.g. the `ws` upgrade hook in src/index.ts).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis'
import type { BetterSidebarService } from './client/service.ts'
/** The request face route handlers see (structural subset of node's
* IncomingMessage: the URL/method/header reads and the async body
* iteration `readJsonBody` uses). */
export interface SidebarHttpRequest {
url?: string
method?: string
headers: Record<string, string | string[] | undefined>
[Symbol.asyncIterator](): AsyncIterator<string | Uint8Array>
}
/** The response face route handlers write to (structural subset of node's
* ServerResponse: the status/header/body writes the routes use). */
export interface SidebarHttpResponse {
statusCode: number
writeHead(status: number, headers?: Record<string, string>): void
end(body?: string | Uint8Array): void
}
/** The upgrade socket face (structural subset: the destroy the fences use). */
export interface SidebarUpgradeSocket {
destroy(): void
}
/** The upgrade head bytes (Buffer at runtime; typed as bytes so no Node
* global leaks into the declaration graph). */
export type SidebarUpgradeHead = Uint8Array
/** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute {
kind: 'exact' | 'prefix'
path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
handler: (req: SidebarHttpRequest, res: SidebarHttpResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
export interface SidebarWebUpgradeRoute {
path: string
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
handler: (req: SidebarHttpRequest, socket: SidebarUpgradeSocket, head: SidebarUpgradeHead) => void | Promise<void>
}
/** The webServer service face this plugin uses. */
+9 -6
View File
@@ -15,8 +15,9 @@
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
import type { Context, SidebarHttpRequest } from './context-types.ts'
import {
Config,
PrefsSchema,
@@ -436,7 +437,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
@@ -669,7 +670,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
// The structural request/socket/head faces satisfy the shared fence;
// the `ws` package wants the real Node types — cast at this boundary.
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
})
},
@@ -691,7 +694,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy()
return
}
agentListWss.handleUpgrade(req, socket, head, (ws) => {
agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req)
})
},
@@ -710,7 +713,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
async function attachAgentList(
registry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
req: SidebarHttpRequest,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
@@ -750,7 +753,7 @@ async function attachTerminal(
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
+10
View File
@@ -109,6 +109,15 @@ export interface SidebarPrefs {
* next matching viewer (or the download button when none match).
*/
viewersEnabled: Record<string, boolean>
/**
* Plugin-owned settings blobs (v0.12.0+), keyed by descriptor id: each
* registered tab/viewer that declares `settings.pluginToggles` (or writes
* through `settings.render`'s `updatePluginSetting`) persists its values
* here — an open map, so third-party keys need no host PrefsSchema field.
* Values are JSON-serializable (the row controls produce strings /
* numbers / booleans; custom panels are responsible for their own).
*/
pluginSettings: Record<string, Record<string, unknown>>
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -138,6 +147,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
+8 -6
View File
@@ -4,7 +4,7 @@
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
* (HTTP 4xx/5xx matching the code) on failure.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
@@ -41,11 +41,13 @@ export interface SidebarOk<T> { ok: true; value: T }
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
export async function readJsonBody(req: SidebarHttpRequest): Promise<unknown> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
// The structural request yields string | Uint8Array; Buffer.from accepts
// both (and the real runtime chunks are node Buffers anyway).
const buffer = Buffer.from(chunk)
total += buffer.length
if (total > MAX_BODY_BYTES) {
throw new SidebarError('bad-request', 'request body too large')
@@ -62,19 +64,19 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
}
/** Write a JSON response with the given status. */
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
export function writeJson(res: SidebarHttpResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(payload)
}
/** Write the success envelope. */
export function writeOk(res: ServerResponse, value: unknown): void {
export function writeOk(res: SidebarHttpResponse, value: unknown): void {
writeJson(res, 200, { ok: true, value })
}
/** Write the failure envelope for any thrown value (unknown → internal 500). */
export function writeError(res: ServerResponse, error: unknown): void {
export function writeError(res: SidebarHttpResponse, error: unknown): void {
if (error instanceof SidebarError) {
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
return