From 291158a3ba53e11c102b6dc0d4f2f4b774f0879b Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 11 Aug 2026 00:31:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=8C=96=E6=94=B9?= =?UTF-8?q?=E9=80=A0=20=E2=80=94=20ctx.betterSidebar=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=EF=BC=88registerTab/registerFileViewer=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=86=85=E7=BD=AE=206=20tab=20+=206=20viewer=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=20registry=EF=BC=8Cv0.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/client/service.ts: BetterSidebarService 接口 + createBetterSidebarService 工厂 - registerTab/registerFileViewer 返回 disposer(HMR-safe,挂到 cordis fiber) - matchFileViewer 三段匹配: priority desc → detect(内容嗅探) → exts - openTab 走 descriptor.dedupeKey 统一去重(替代 isSingle/editor-path/diff-id 三分支) - createTab 回调支持自定义 tab 创建(terminal 的 nextTerminal 递增) - 新增 src/client/builtins.tsx: 6 内置 tab + 6 内置 viewer 自我注册(吃狗粮) - tab: editor(hidden) / explorer / git / subagent / terminal / diff(hidden) - viewer: image / pdf / docx / xlsx / pptx / binary-download - code/markdown 保持 EditorView 内联兜底(matchFileViewer 返回 undefined 时走 fsRead+CodeMirror) - 新增 src/client/OrphanedTab.tsx: 未注册 type 的占位组件(插件未加载时降级) - 改造 src/client/index.tsx: ctx.provide('betterSidebar') + registerBuiltins - 改造 src/client/Sidebar.tsx: TabContent/buildNewTabOptions/onNewTab 查表替代 switch - 改造 src/client/EditorView.tsx: 预览路由查 matchFileViewer 替代 if/else 链 - 改造 src/client/state.ts: TabType 联合字面量→string,sanitizeNode 接受所有 string type - 改造 src/context-types.ts: declare module 'cordis' 加 betterSidebar 字段 - 改造 src/index.ts: re-export Context + 服务类型(consumer import type 触发 augmentation) - package.json: 加 ./client/service exports 子路径,version 0.4.0 - tests/service.spec.ts: 12 个新测试覆盖 register/dispose/matchFileViewer/dedupe/createTab - tests/unit.spec.ts: sanitizeState 测试更新(未知 type 不再被拒绝,渲染 OrphanedTab) --- dsh.plugin.json | 4 +- package.json | 8 +- src/client/EditorView.tsx | 105 ++++++------- src/client/OrphanedTab.tsx | 27 ++++ src/client/Sidebar.tsx | 117 ++++----------- src/client/builtins.tsx | 214 +++++++++++++++++++++++++++ src/client/icons.tsx | 1 + src/client/index.tsx | 15 ++ src/client/locales.ts | 2 + src/client/service.ts | 294 +++++++++++++++++++++++++++++++++++++ src/client/state.ts | 24 ++- src/context-types.ts | 7 + src/index.ts | 13 ++ tests/service.spec.ts | 189 ++++++++++++++++++++++++ tests/unit.spec.ts | 14 +- 15 files changed, 878 insertions(+), 156 deletions(-) create mode 100644 src/client/OrphanedTab.tsx create mode 100644 src/client/builtins.tsx create mode 100644 src/client/service.ts create mode 100644 tests/service.spec.ts diff --git a/dsh.plugin.json b/dsh.plugin.json index 315fb0c..82ce434 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,8 +1,8 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.3.0", + "version": "0.4.0", "main": "./lib/index.js", - "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离", + "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { "dsh": ">=0.0.1" }, diff --git a/package.json b/package.json index 91c82e2..c4e76db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-better-sidebar", - "version": "0.3.0", - "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.", + "version": "0.4.0", + "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "private": true, "type": "module", "main": "lib/index.js", @@ -19,6 +19,10 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./client/service": { + "types": "./lib/types/client/service.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index 02b380c..86f9e39 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -7,23 +7,20 @@ * and unsaved edits survive the toggle. Reads cap at the host's 512KB bound * (a banner marks truncation). */ -import { useEffect, useRef, useState } from 'react' +import { createElement, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { EditorState } from '@codemirror/state' import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { Context } from '../context-types.ts' import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts' import { languageForPath } from './lang.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { t } from './locales.ts' -import { officeKindForExt } from './office-types.ts' -import { DocxView, XlsxView } from './office-view.tsx' -import { isPdfExt } from './pdf-types.ts' -import { PdfView } from './PdfView.tsx' -import { PptxView } from './PptxView.tsx' -import { isImageExt } from './image-types.ts' +import type { FileViewerDescriptor } from './service.ts' +import type { SidebarStore } from './state.ts' import css from './sidebar.module.css' const MD_EXT = ['.md', '.markdown'] @@ -31,14 +28,14 @@ const MD_EXT = ['.md', '.markdown'] type EditorLoad = | { status: 'loading' } | { status: 'error'; message: string } - | { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean } + | { status: 'ready'; viewer?: FileViewerDescriptor; kind?: 'text' | 'md'; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } | { status: 'binary' } // unknown binary / OLE legacy formats → download button /** Previewable files (rendered output vs source editing). */ type ViewMode = 'preview' | 'edit' -export function EditorView(props: { scope: SessionScope; path: string; title: string }) { - const { scope, path, title } = props +export function EditorView(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { + const { ctx, store, scope, path, title } = props const [load, setLoad] = useState({ status: 'loading' }) const [mode, setMode] = useState('preview') /** The editor's current text (null while clean); preview renders this. */ @@ -62,30 +59,44 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st setDraft(null) setDirty(false) setSaveState('idle') - // Office preview short-circuit: the host's readText would NUL-probe every - // Office file as binary (a wasted round-trip), and .docx/.xlsx have their - // own renderers below. Detect by extension and skip fsRead entirely — - // the renderer fetches bytes through the media route itself. const ext = extOfPath(path) - // Images are binary by nature; short-circuit before fsRead's NUL probe or - // every PNG/JPEG would be classified as an unsupported binary file. - if (isImageExt(ext)) { - setLoad({ status: 'ready', kind: 'image', content: '', truncated: false }) - return - } - if (isPdfExt(ext)) { - setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false }) - return - } - const officeKind = officeKindForExt(ext) - if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') { - setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false }) - return - } - if (officeKind === 'download-only') { - setLoad({ status: 'binary' }) - return + // Registry-driven viewer match: image/pdf/office/binary-download etc. + // are registered through ctx.betterSidebar; code/markdown have no + // registered viewer and fall through to the fsRead + CodeMirror path. + const viewer = ctx.betterSidebar?.matchFileViewer(path) + if (viewer !== undefined) { + switch (viewer.fetchStrategy) { + case 'mediaUrl': + case 'none': + setLoad({ status: 'ready', viewer, mediaUrl: mediaUrl(scope, path) }) + return + case 'binary-download': + setLoad({ status: 'binary' }) + return + case 'custom': + void viewer.load?.(path, scope).then((data) => { + if (cancelled) return + setLoad({ status: 'ready', viewer, customData: data }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + case 'fsRead': + // An fsRead viewer (e.g. external CSV viewer) gets its content + // through the host's fs.read and renders through viewer.component. + api.fsRead(scope, path).then((result) => { + if (cancelled) return + if (result.kind === 'binary') { setLoad({ status: 'binary' }); return } + setLoad({ status: 'ready', viewer, content: result.content, truncated: result.truncated }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + } } + // Fallback: code/markdown (no registered viewer) — fsRead + CodeMirror. api.fsRead(scope, path).then((result) => { if (cancelled) return if (result.kind === 'binary') { @@ -103,7 +114,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) }) return () => { cancelled = true } - }, [scope.sessionId, scope.cwd, path]) + }, [scope.sessionId, scope.cwd, path, ctx]) // Create the CodeMirror editor once the file is loaded. The view owns the // document; React only tracks dirty/draft state through the update @@ -112,7 +123,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st // colors live in a compartment so a scheme flip reconfigures only that // part — the document, undo history and scroll position survive. useEffect(() => { - if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return + if (load.status !== 'ready' || load.viewer !== undefined || (load.kind !== 'text' && load.kind !== 'md')) return const host = hostRef.current if (host === null) return const language = languageForPath(path) @@ -248,26 +259,16 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st /> )} - {load.status === 'ready' && load.kind === 'image' && ( -
- {title} -
- )} - {load.status === 'ready' && load.kind === 'docx' && ( - - )} - {load.status === 'ready' && load.kind === 'xlsx' && ( - - )} - {load.status === 'ready' && load.kind === 'pptx' && ( - - )} - {load.status === 'ready' && load.kind === 'pdf' && ( - - )} + {load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, { + ctx, store, scope, path, title, + content: load.content, + truncated: load.truncated, + mediaUrl: load.mediaUrl, + customData: load.customData, + })} {previewable && mode === 'preview' && (
- +
)} diff --git a/src/client/OrphanedTab.tsx b/src/client/OrphanedTab.tsx new file mode 100644 index 0000000..c62ce8b --- /dev/null +++ b/src/client/OrphanedTab.tsx @@ -0,0 +1,27 @@ +/** + * Placeholder shown when a persisted tab's type is not registered (the + * contributing plugin is not installed / not yet loaded). The tab is kept + * in state so it can recover if the plugin loads later; the user can close + * it through the tab bar's X button as usual. This is the graceful- + * degradation path for the open tab-type set: {@link Sidebar} renders this + * instead of crashing on an unknown type. + */ +import type { ReactNode } from 'react' +import { t } from './locales.ts' +import css from './sidebar.module.css' +import type { TabComponentProps } from './service.ts' + +export function OrphanedTab(props: TabComponentProps): ReactNode { + const { tab } = props + return ( +
+
+ {tab.title} +
+
+ {t('pluginNotLoaded')} + {tab.type} +
+
+ ) +} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 77bdab8..1f824a2 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -23,18 +23,11 @@ import { import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' -import { tabTypeIcon } from './icons.tsx' import { relativeTo } from './paths.ts' -import { ExplorerView } from './ExplorerView.tsx' -import { EditorView } from './EditorView.tsx' -import { TerminalView } from './TerminalView.tsx' -import { GitView } from './GitView.tsx' -import { DiffTab } from './DiffTab.tsx' -import { SubagentView } from './SubagentView.tsx' +import { OrphanedTab } from './OrphanedTab.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' import { t } from './locales.ts' import { api } from './api.ts' -import { openSidebarFile } from './intercept.tsx' import css from './sidebar.module.css' /** How many consecutive reconnect failures stop the agent-terminals push loop @@ -60,64 +53,31 @@ function TabContent(props: { }) { const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props const scope = { sessionId, cwd } - switch (tab.type) { - case 'explorer': - return ( - { openSidebarFile(ctx, store, sessionId, path) }} - onReferenceFile={onReferenceFile} - /> - ) - case 'git': - return ( - { openSidebarFile(ctx, store, sessionId, path) }} - onOpenDiff={onOpenDiff} - /> - ) - case 'diff': - return tab.diff === undefined - ? null - : - case 'terminal': - return - case 'editor': - return - case 'subagent': - return ( - { onSubagentJump(address.childSessionId) }} - /> - ) + const descriptor = ctx.betterSidebar?.getTab(tab.type) + if (descriptor === undefined) { + return } + return descriptor.component({ + ctx, store, scope, tab, visible, expanded, + onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, + }) } -/** The + menu options for the current state (UI-tab terminal cap applied). - * Agent-owned terminals (`agent:` tabs) are the model's, not the user's: - * they never count toward the per-session UI cap. */ -function buildNewTabOptions(state: SidebarState): NewTabOption[] { - const uiTerminalCount = allLeaves(state.splits) - .flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length - return [ - { id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) }, - { id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) }, - { id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) }, - { - id: 'terminal', - label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), - disabled: uiTerminalCount >= TERMINAL_LIMIT, - icon: tabTypeIcon('terminal', 16), - }, - ] +/** The + menu options for the current state, driven by the tab registry. + * Hidden tabs (editor/diff) never show; `available` returning false shows + * a disabled row (e.g. terminal at capacity) instead of hiding the option. */ +function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] { + const service = ctx.betterSidebar + if (service === undefined) return [] + return service.getTabs() + .filter(d => !d.hidden) + .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) + .map(d => ({ + id: d.id, + label: typeof d.title === 'function' ? d.title() : d.title, + disabled: !(d.available?.(state) ?? true), + icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, + })) } export function Sidebar(props: { ctx: Context; store: SidebarStore }) { @@ -367,30 +327,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } const onNewTab = (optionId: string): void => { - store.reduce((s) => { - if (optionId === 'explorer') { - return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') }) - } - if (optionId === 'git') { - return openTab(s, { id: 'git', type: 'git', title: t('git') }) - } - if (optionId === 'subagent') { - return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') }) - } - if (optionId === 'terminal') { - const count = allLeaves(s.splits) - .flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length - if (count >= TERMINAL_LIMIT) return s - const tab: SidebarTab = { - id: `terminal:${s.nextTerminal}`, - type: 'terminal', - title: `${t('terminal')} ${s.nextTerminal}`, - } - return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 } - } - return s - }) + const service = ctx.betterSidebar + const descriptor = service?.getTab(optionId) + if (descriptor === undefined) return + const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title + service.openTab({ type: optionId, title }) } /** @@ -498,7 +439,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
leaf.tabs) + .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length +} + +/** The 6 built-in tab descriptors. */ +function builtinTabs(ctx: Context): readonly TabDescriptor[] { + return [ + { + id: 'editor', + title: () => t('editor'), + icon: (size: number) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.path, + component: ({ ctx, store, scope, tab }) => ( + + ), + }, + { + id: 'explorer', + title: () => t('explorer'), + icon: (size: number) => , + order: 10, + dedupeKey: () => 'explorer', + component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( + { /* no-op */ })} + onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} + onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'git', + title: () => t('git'), + icon: (size: number) => , + order: 20, + dedupeKey: () => 'git', + component: ({ ctx, store, scope, onOpenDiff }) => ( + { openSidebarFile(ctx, store, scope.sessionId, path) }} + onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })} + /> + ), + }, + { + id: 'subagent', + title: () => t('subagent'), + icon: (size: number) => , + order: 30, + dedupeKey: () => 'subagent', + component: ({ ctx, scope, visible, onSubagentJump }) => ( + { onSubagentJump?.(address.childSessionId) }} + /> + ), + }, + { + id: 'terminal', + title: () => t('terminal'), + icon: (size: number) => , + order: 40, + available: (state) => uiTerminalCount(state) < TERMINAL_LIMIT, + createTab: (state) => { + const count = uiTerminalCount(state) + if (count >= TERMINAL_LIMIT) return null + return { + tab: { + id: `terminal:${state.nextTerminal}`, + type: 'terminal', + title: `${t('terminal')} ${state.nextTerminal}`, + }, + patch: { nextTerminal: state.nextTerminal + 1 }, + } + }, + component: ({ scope, tab, store }) => ( + + ), + }, + { + id: 'diff', + title: () => t('git'), + icon: (size: number) => , + order: -1, + hidden: true, + dedupeKey: (tab) => tab.id, + component: ({ scope, tab }) => ( + tab.diff === undefined ? null + : + ), + }, + ] +} + +/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */ +const builtinViewers: readonly FileViewerDescriptor[] = [ + { + id: 'image', + exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'], + fetchStrategy: 'mediaUrl', + component: ({ mediaUrl: url, title }) => ( +
+ {title} +
+ ), + }, + { + id: 'pdf', + exts: ['pdf'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'docx', + exts: ['docx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'xlsx', + exts: ['xlsx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'pptx', + exts: ['pptx'], + fetchStrategy: 'mediaUrl', + component: ({ scope, path, title }) => ( + + ), + }, + { + id: 'binary-download', + exts: ['doc', 'xls', 'ppt'], + priority: -50, + fetchStrategy: 'binary-download', + component: ({ scope, path }) => ( +
+ {t('binaryNoPreview')} + + {t('downloadToView')} + +
+ ), + }, +] + +/** + * Register all built-in tabs and viewers with the service. Returns a + * disposer that unregisters everything (cordis auto-invokes it on fiber + * disposal). The `ctx` is threaded into tab descriptors that need it + * (EditorView reads `ctx.betterSidebar` for file-viewer matching). + */ +export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void { + const disposers: (() => void)[] = [] + for (const tab of builtinTabs(ctx)) { + disposers.push(service.registerTab(tab)) + } + for (const viewer of builtinViewers) { + disposers.push(service.registerFileViewer(viewer)) + } + return () => { + for (const d of disposers) { + try { d() } catch { /* already disposed */ } + } + } +} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index e9b2b90..2e0e30e 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -40,5 +40,6 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode { case 'subagent': return case 'editor': return case 'diff': return + default: return null } } diff --git a/src/client/index.tsx b/src/client/index.tsx index b725752..9589995 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -12,6 +12,8 @@ import { Component, createElement, type ErrorInfo, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' import { createSidebarStore } from './state.ts' +import { createBetterSidebarService } from './service.ts' +import { registerBuiltins } from './builtins.tsx' import { Sidebar } from './Sidebar.tsx' import { registerTurnTailInterception } from './intercept.tsx' import { loadPrefs } from './prefs.ts' @@ -69,6 +71,19 @@ export function apply(ctx: Context): void { // registrations (the official createXXXStore() factory rule — no // module-level singleton). const sidebarStore = createSidebarStore() + // The sidebar registry service: external plugins register tab types and + // file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`. + // Published before the panel mounts so consumers injecting 'betterSidebar' + // are ready by the time the sidebar renders. + const service = createBetterSidebarService(sidebarStore) + ctx.provide('betterSidebar', service) + // Register the plugin's own built-in tabs and viewers through the same + // service (eating our own dogfood). The disposer unregisters them on + // fiber disposal (HMR-safe). + ctx.effect( + () => registerBuiltins(ctx, service), + 'dsh-better-sidebar: register built-in tabs and viewers', + ) // A failure anywhere in the client lifecycle must never take the app down // silently: log with the plugin prefix and pin a visible diagnostic strip // to the page so a blank panel is never the only symptom. diff --git a/src/client/locales.ts b/src/client/locales.ts index 87f057a..f96655b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -87,6 +87,7 @@ const zh = { disconnected: '终端连接断开,重连中…', exited: '终端进程已退出', noSession: '选择一个会话以使用侧边栏', + pluginNotLoaded: '插件未加载,标签页暂不可用:', hiddenFiles: '隐藏文件', parent: '上级目录', copied: '已复制', @@ -219,6 +220,7 @@ const en: Record = { disconnected: 'Terminal disconnected, reconnecting…', exited: 'Terminal process exited', noSession: 'Select a conversation to use the sidebar', + pluginNotLoaded: 'Plugin not loaded; tab unavailable:', hiddenFiles: 'Hidden files', parent: 'Parent directory', copied: 'Copied', diff --git a/src/client/service.ts b/src/client/service.ts new file mode 100644 index 0000000..b9fe077 --- /dev/null +++ b/src/client/service.ts @@ -0,0 +1,294 @@ +/** + * The BetterSidebar client service: a registry that external plugins use + * to contribute sidebar tab types and file previewers. The service is + * published to the cordis context as `ctx.betterSidebar` (see + * {@link ../types.ts}); consumers declare it in `inject` and call + * `registerTab` / `registerFileViewer`, both returning a disposer that + * cordis auto-invokes on fiber disposal (HMR-safe). + * + * Design notes: + * - The registry is synchronous-snapshot (Map + listener set) so React + * can read it through `useSyncExternalStore` without tearing. + * - `dedupeKey` unifies the three open-tab strategies the builtins used to + * hardcode: single-instance (`() => type`), per-path (`tab => tab.path`), + * and per-id (`tab => tab.id` for diff tabs whose id is change-derived). + * - `createTab` lets a descriptor own tab instantiation (the terminal + * builtin uses it to mint `terminal:` ids and bump `nextTerminal`). + * - `matchFileViewer` resolves viewers by priority (desc), then `detect` + * (content sniff), then `exts` (lowercase, no dot). + */ +import type { ReactNode } from 'react' +import type { Context } from '../context-types.ts' +import { + activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf, + type SidebarState, type SidebarStore, type SidebarTab, +} from './state.ts' +import type { SessionScope } from './api.ts' + +/** Props every tab component receives (builtins and external alike). */ +export interface TabComponentProps { + ctx: Context + store: SidebarStore + scope: SessionScope + tab: SidebarTab + /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */ + visible: boolean + /** The explorer's expanded directory set (ExplorerView). */ + expanded?: string[] + onToggleDir?: (path: string) => void + onReferenceFile?: (path: string) => void + onOpenFile?: (path: string) => void + onOpenDiff?: (tab: SidebarTab) => void + onSubagentJump?: (childSessionId: string) => void +} + +/** Describes one kind of sidebar tab (builtins register themselves too). */ +export interface TabDescriptor { + /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */ + id: string + title: string | (() => string) + icon?: ReactNode | ((size: number) => ReactNode) + /** + menu sort order (ascending); default 100. */ + order?: number + /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */ + hidden?: boolean + /** + menu disabled predicate (e.g. terminal at capacity). */ + available?: (state: SidebarState) => boolean + /** + * If provided, opening a tab whose `dedupeKey(tab)` matches an existing + * tab's key focuses the existing one instead of creating a new one. + * Returning `undefined` means "no dedup — always open a new tab". + * Builtins: explorer/git/subagent use `() => id`; editor uses `tab => tab.path`; + * diff uses `tab => tab.id` (openDiffTab mints change-derived ids). + */ + dedupeKey?: (tab: SidebarTab) => string | undefined + /** + * Custom tab creation (minting the `SidebarTab` and any state patches). + * Return `null` to refuse creation. The terminal builtin uses this to + * mint `terminal:` ids and bump `nextTerminal`. + * When omitted, a default `{ id, type, title }` tab is created. + */ + createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + component: (props: TabComponentProps) => ReactNode +} + +/** How the host loads a file's bytes for one viewer. */ +export type FileFetchStrategy = + | 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves) + | 'fsRead' // text read through /sidebar/api fs.read + | 'mediaUrl' // the viewer gets a media URL string + | 'custom' // the viewer's load() fetches its own bytes + | 'binary-download' // show a download button (no client-side renderer) + +/** Props every file viewer component receives. */ +export interface FileViewerProps { + ctx: Context + store: SidebarStore + scope: SessionScope + path: string + title: string + /** fsRead text content (fetchStrategy='fsRead'). */ + content?: string + truncated?: boolean + /** mediaUrl for the path (fetchStrategy='mediaUrl'). */ + mediaUrl?: string + /** custom load() return value (fetchStrategy='custom'). */ + customData?: unknown +} + +/** Describes one file previewer (builtins register themselves too). */ +export interface FileViewerDescriptor { + /** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */ + id: string + /** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */ + exts: readonly string[] + /** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */ + priority?: number + fetchStrategy: FileFetchStrategy + /** + * Content sniff (overrides exts when `head` bytes are available). + * The first descriptor (priority desc) whose `detect` returns true wins. + */ + detect?: (path: string, head: Uint8Array) => boolean + /** fetchStrategy='custom' loader. */ + load?: (path: string, scope: SessionScope) => Promise + component: (props: FileViewerProps) => ReactNode +} + +/** The registry service published as `ctx.betterSidebar`. */ +export interface BetterSidebarService { + registerTab(descriptor: TabDescriptor): () => void + registerFileViewer(descriptor: FileViewerDescriptor): () => void + getTabs(): readonly TabDescriptor[] + getFileViewers(): readonly FileViewerDescriptor[] + /** Find a tab descriptor by id (undefined if not registered). */ + getTab(id: string): TabDescriptor | undefined + /** Find a file viewer for a path (priority desc → detect → exts). */ + matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined + /** Open a tab (used by external tabs and the + menu). */ + openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void + /** Close a tab by id. */ + closeTab(tabId: string): void + /** Subscribe to registry changes (register/dispose). */ + subscribe(listener: () => void): () => void +} + +/** Extract the lowercase extension without leading dot from a path. */ +function extOfPath(path: string): string { + const at = path.lastIndexOf('.') + if (at === -1) return '' + const base = path.slice(at + 1).toLowerCase() + return base.includes('/') || base.includes('\\') ? '' : base +} + +/** + * Create one BetterSidebar service bound to a store. The service owns the + * tab/viewer registries (Map + listener set) and proxies openTab/closeTab + * to the store's reducer. One instance per client plugin activation. + */ +export function createBetterSidebarService(store: SidebarStore): BetterSidebarService { + const tabs = new Map() + const viewers = new Map() + const listeners = new Set<() => void>() + + const notify = (): void => { + for (const fn of [...listeners]) fn() + } + + const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + } + + const registerTab = (descriptor: TabDescriptor): (() => void) => { + if (tabs.has(descriptor.id)) { + throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`) + } + tabs.set(descriptor.id, descriptor) + notify() + return () => { + if (tabs.get(descriptor.id) === descriptor) { + tabs.delete(descriptor.id) + notify() + } + } + } + + const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => { + if (viewers.has(descriptor.id)) { + throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`) + } + viewers.set(descriptor.id, descriptor) + notify() + return () => { + if (viewers.get(descriptor.id) === descriptor) { + viewers.delete(descriptor.id) + notify() + } + } + } + + const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values()) + const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values()) + const getTab = (id: string): TabDescriptor | undefined => tabs.get(id) + + const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => { + const ext = extOfPath(path) + // Priority descending; stable order for equal priorities (insertion order). + const sorted = Array.from(viewers.values()).sort( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0), + ) + // Pass 1: content sniff (detect overrides exts when head bytes are available). + if (head !== undefined) { + for (const v of sorted) { + if (v.detect !== undefined && v.detect(path, head)) return v + } + } + // Pass 2: extension match ([] is catch-all, skipped here). + for (const v of sorted) { + if (v.exts.length === 0) continue + if (v.exts.includes(ext)) return v + } + // Pass 3: catch-all (exts === []). + for (const v of sorted) { + if (v.exts.length === 0) return v + } + return undefined + } + + const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff'] }): void => { + store.reduce((state) => { + const descriptor = tabs.get(seed.type) + if (descriptor === undefined) return state + // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). + if (descriptor.createTab !== undefined) { + const result = descriptor.createTab(state) + if (result === null) return state + const next = applyDedupe(state, result.tab, descriptor) + return result.patch !== undefined ? { ...next, ...result.patch } : next + } + const tab: SidebarTab = { + id: seed.type, + type: seed.type, + title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title, + ...(seed.path !== undefined ? { path: seed.path } : {}), + ...(seed.diff !== undefined ? { diff: seed.diff } : {}), + } + return applyDedupe(state, tab, descriptor) + }) + } + + const closeTab = (tabId: string): void => { + store.reduce((state) => { + const paneId = findPaneIdOf(state, tabId) + if (paneId === '') return state + return closeTabReducer(state, paneId, tabId) + }) + } + + return { + registerTab, + registerFileViewer, + getTabs, + getFileViewers, + getTab, + matchFileViewer, + openTab, + closeTab, + subscribe, + } +} + +/** + * Apply dedup: if a tab with the same dedupeKey exists, focus it instead. + * Mirrors the three branches the old `openTab` hardcoded (single/editor/diff). + */ +function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { + const key = descriptor.dedupeKey?.(tab) + if (key !== undefined) { + for (const leaf of allLeaves(state.splits)) { + const existing = leaf.tabs.find(t => t.type === tab.type && descriptor.dedupeKey!(t) === key) + if (existing !== undefined) return activateTab(state, leaf.id, existing.id) + } + } + let targetId = state.activePane ?? firstLeaf(state.splits).id + if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) { + targetId = firstLeaf(state.splits).id + } + return { + ...state, + activePane: targetId, + splits: mapLeaf(state.splits, targetId, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }), + } +} + +/** Find which pane hosts a tab id ('' if none). */ +function findPaneIdOf(state: SidebarState, tabId: string): string { + for (const leaf of allLeaves(state.splits)) { + if (leaf.tabs.some(t => t.id === tabId)) return leaf.id + } + return state.activePane ?? '' +} diff --git a/src/client/state.ts b/src/client/state.ts index be76828..82eb64a 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -11,7 +11,18 @@ */ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' -export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff' +/** + * Tab type identifier. Builtins use the strings below; external plugins + * register their own ids (e.g. `'my-plugin:db'`) through the sidebar + * service. Kept as `string` so the registry is open; {@link SIDEBAR_BUILTIN_TAB_TYPES} + * lists the builtins for back-compat checks. + */ +export type TabType = string + +/** The tab type ids the plugin itself registers (see {@link ./builtins.ts}). */ +export const SIDEBAR_BUILTIN_TAB_TYPES = [ + 'explorer', 'git', 'editor', 'terminal', 'subagent', 'diff', +] as readonly string[] /** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */ export type SidebarDiffRef = @@ -622,13 +633,10 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map at view time and recovers if its plugin loads later. + if (typeof candidate.type !== 'string') return undefined tabs.push({ id: candidate.id, type: candidate.type, diff --git a/src/context-types.ts b/src/context-types.ts index 7994734..aab1b7a 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -20,6 +20,7 @@ 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' /** One named webserver route (mirror of the host-webserver WebRoute). */ export interface SidebarWebRoute { @@ -309,6 +310,12 @@ declare module 'cordis' { settings: SidebarSettingsService invariants: SidebarInvariantsService tools: SidebarToolsService + /** + * The client-side sidebar registry: external plugins register tab types + * and file previewers here. Provided by the client half (see + * {@link ./client/index.tsx}); undefined on the host side. + */ + betterSidebar: BetterSidebarService /** * Register a lifecycle callback (DSH-vendored cordis): runs at plugin * activation; its returned cleanup runs at disposal. diff --git a/src/index.ts b/src/index.ts index 0c5ddda..274720c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,19 @@ import { readJsonBody, requireString, SidebarError, writeError, writeJson, write export { Config } export type { SidebarConfig, ResolvedSidebarConfig } +// Re-export the Context augmentation (declare module 'cordis') so consumers +// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`. +// Also re-export the service descriptor types so consumers can type their +// registerTab / registerFileViewer arguments without reaching into /client. +export type { Context } from './context-types.ts' +export type { + BetterSidebarService, + TabDescriptor, + TabComponentProps, + FileViewerDescriptor, + FileViewerProps, + FileFetchStrategy, +} from './client/service.ts' /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' diff --git a/tests/service.spec.ts b/tests/service.spec.ts new file mode 100644 index 0000000..d929d2f --- /dev/null +++ b/tests/service.spec.ts @@ -0,0 +1,189 @@ +/** + * Tests for the BetterSidebar service registry: register/dispose lifecycle, + * matchFileViewer priority/exts/detect algorithm, and openTab dedupe. + */ +import { describe, it, expect } from 'vitest' + +// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout) +const g = globalThis as Record +if (g.window === undefined) { + g.window = { + clearTimeout: () => {}, + setTimeout: (_fn: () => void) => 0, + innerWidth: 1024, + } +} +if (g.localStorage === undefined) { + g.localStorage = { + getItem: () => null, + setItem: () => {}, + } +} + +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore, allLeaves } from '../src/client/state.ts' + +describe('BetterSidebar service', () => { + it('registerTab adds to the registry and dispose removes it', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + expect(service.getTabs()).toHaveLength(0) + const dispose = service.registerTab({ + id: 'test:tab', + title: 'Test', + component: () => null, + }) + expect(service.getTabs()).toHaveLength(1) + expect(service.getTab('test:tab')?.id).toBe('test:tab') + dispose() + expect(service.getTabs()).toHaveLength(0) + expect(service.getTab('test:tab')).toBeUndefined() + }) + + it('registerTab throws on duplicate id', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'dup', title: 'A', component: () => null }) + expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow() + }) + + it('registerFileViewer adds and dispose removes', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + expect(service.getFileViewers()).toHaveLength(0) + const dispose = service.registerFileViewer({ + id: 'csv', + exts: ['csv'], + fetchStrategy: 'custom', + component: () => null, + }) + expect(service.getFileViewers()).toHaveLength(1) + dispose() + expect(service.getFileViewers()).toHaveLength(0) + }) + + it('subscribe fires on register and dispose', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + let calls = 0 + const unsub = service.subscribe(() => { calls++ }) + const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null }) + expect(calls).toBe(1) + dispose() + expect(calls).toBe(2) + unsub() + service.registerTab({ id: 'y', title: 'Y', component: () => null }) + expect(calls).toBe(2) + }) +}) + +describe('matchFileViewer', () => { + it('matches by extension', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('photo.png')?.id).toBe('img') + expect(service.matchFileViewer('photo.JPG')?.id).toBe('img') + expect(service.matchFileViewer('doc.txt')).toBeUndefined() + }) + + it('higher priority wins on extension conflict', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null }) + service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null }) + expect(service.matchFileViewer('x.png')?.id).toBe('advanced') + }) + + it('catch-all (exts: []) matches anything at lowest priority', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null }) + service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('x.png')?.id).toBe('img') + expect(service.matchFileViewer('x.txt')?.id).toBe('catchall') + }) + + it('detect overrides exts when head bytes are available', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null }) + service.registerFileViewer({ + id: 'by-magic', + exts: [], + priority: 5, + fetchStrategy: 'fsRead', + detect: (_path, head) => head[0] === 0x89, + component: () => null, + }) + // Without head bytes, by-ext wins (priority 10 > 5, and .bin matches). + expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext') + // With magic bytes, by-magic wins (detect overrides). + expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic') + }) + + it('returns undefined when no viewer matches and no catch-all', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null }) + expect(service.matchFileViewer('doc.txt')).toBeUndefined() + }) +}) + +describe('service.openTab dedupe', () => { + it('dedupeKey focuses existing tab instead of duplicating', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'singleton', + title: 'Singleton', + dedupeKey: () => 'singleton', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'singleton', title: 'Singleton' }) + service.openTab({ type: 'singleton', title: 'Singleton' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs) + expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1) + }) + + it('no dedupeKey opens a new tab each time', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'multi', + title: 'Multi', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'multi', title: 'Multi' }) + service.openTab({ type: 'multi', title: 'Multi' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs) + expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2) + }) + + it('createTab mints custom ids and patches state', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'counter', + title: 'Counter', + createTab: (state) => ({ + tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` }, + patch: { nextTerminal: state.nextTerminal + 1 }, + }), + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'counter', title: 'Counter' }) + service.openTab({ type: 'counter', title: 'Counter' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter') + expect(tabs).toHaveLength(2) + expect(tabs[0]!.id).toBe('counter:1') + expect(tabs[1]!.id).toBe('counter:2') + expect(state.nextTerminal).toBe(3) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index b4a9fa9..2a36ba1 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -644,10 +644,16 @@ describe('persisted state sanitization', () => { const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400))) withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] } expect(sanitizeState(withSplit)).toBeUndefined() - // Unknown tab types (older layouts) are rejected. - const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400))) - withBadTab.splits.tabs[0].type = 'watcher' - expect(sanitizeState(withBadTab)).toBeUndefined() + // Unknown tab types (external plugins not yet loaded) are accepted — + // they render as at view time and recover if the plugin + // loads later. Only diff tabs are dropped (ephemeral). + const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400))) + withExternalTab.splits.tabs[0].type = 'my-plugin:db' + const externalClean = sanitizeState(withExternalTab) + expect(externalClean).toBeDefined() + if (externalClean !== undefined && externalClean.splits.kind === 'leaf') { + expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db') + } // An active id that no tab carries is rejected. const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400))) withBadActive.splits.active = 'ghost-tab'