feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer),内置 6 tab + 6 viewer 迁移到 registry,v0.4.0
- 新增 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)
This commit is contained in:
+29
-88
@@ -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 (
|
||||
<ExplorerView
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
)
|
||||
case 'git':
|
||||
return (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff}
|
||||
/>
|
||||
)
|
||||
case 'diff':
|
||||
return tab.diff === undefined
|
||||
? null
|
||||
: <DiffTab sessionId={sessionId} cwd={cwd} diff={tab.diff} />
|
||||
case 'terminal':
|
||||
return <TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
case 'editor':
|
||||
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
case 'subagent':
|
||||
return (
|
||||
<SubagentView
|
||||
sessionId={sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump(address.childSessionId) }}
|
||||
/>
|
||||
)
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
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 }) {
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state)}
|
||||
newTabOptions={buildNewTabOptions(state, ctx)}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
|
||||
Reference in New Issue
Block a user