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:
+4
-5
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user