Files
apes-sidebar/src/client/builtins/tabs.tsx
T
lhh010 02d3936b92 feat: 新增第 8 个内置 tab「文件追踪」——移植 dsh-file-trace 插件
- 数据层:直接折叠会话事件日志(tool/call + tool/result 对)提取文件操作,
  与仓库「读事件日志而非动注册表」的取向一致;含错误文本捕获与
  running 态实时更新(ctx.on('session/event'),不可见时暂停、恢复时追平)
- 视图:操作列表 + 底部可拖拽 diff 面板;行级 diff(删红/增绿/改蓝)+
  行内字符级高亮;读取视图带真实行号;失败操作统一展示真实错误文本
- 语法着色:轻量分词器(关键字/字符串/数字/类型/函数/注释/预处理),
  色相经 color-mix 落在 diff 三色之间的空档;跨行块注释状态按行序推导
- 单实例 tab,+ 菜单 order 55,角标显示操作数;设置页可整体关闭
  (与独立维护的 dsh-file-trace 插件共存)
- i18n:10 个新 key 同步全部 21 个词典;builtins 测试 7→8;
  README/指南内置清单同步
2026-08-30 20:59:50 +08:00

367 lines
14 KiB
TypeScript

/**
* The 8 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff /
* file-trace) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconHistoryOutline16 } from '../icons.tsx'
import { FileTraceTab } from '../filetrace/FileTraceTab.tsx'
import { extractFileOps } from '../filetrace/ops.ts'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
import type { TabDescriptor } from '../service.ts'
/**
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
* only when a terminal tab is first opened (see chunk-loader.ts). The
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
* calls it as a plain function.
*
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
* descriptor maps it explicitly; a bare pass-through would leave tabId
* undefined and TerminalView's isAgentTabId(tabId) would crash on
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
*/
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
'terminal',
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
)
/** The terminal view's props (mirror of TerminalView's own signature). */
interface TerminalViewProps {
scope: SessionScope
tabId: string
store: SidebarStore
}
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Optional per-registration builtin behavior (currently terminal title). */
export interface BuiltinTabOptions {
/** Returns the display title for newly opened terminal tabs. */
terminalTitle?: () => string
}
/** A client-side uuid for terminal tab identity (not shown in the UI). */
function terminalUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
return [
{
id: 'editor',
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
title: () => t('files'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
hidden: false,
dedupeKey: (tab) => tab.path,
// Declarative settings: the file-open behavior picker (in-place switch
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page, followed by the
// workspace fence switch (the host's containment guard over every
// sidebar filesystem route); the "open with" configuration (SSH host +
// custom editors) is the custom panel BELOW those rows — the settings
// seam renders rows first, custom panel after.
settings: {
toggles: [{
key: 'editorExplorer',
type: 'select',
title: () => t('editorExplorer'),
desc: () => t('editorExplorerDesc'),
options: [
{
value: true,
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
title: () => t('editorExplorerMerged'),
desc: () => t('editorExplorerMergedDesc'),
},
{
value: false,
icon: (size: number) => <IconCodeOutline16 size={size} />,
title: () => t('editorExplorerSplit'),
desc: () => t('editorExplorerSplitDesc'),
},
],
}, {
key: 'workspaceFence',
title: () => t('settingsFenceTitle'),
desc: () => t('settingsFenceDesc'),
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
),
},
component: ({ ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
expanded={expanded ?? []}
revealed={revealed ?? []}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, visible, onOpenDiff }) => (
<GitView
scope={scope}
store={store}
visible={visible}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
// Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Jobs page's own related settings).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}, {
key: 'autoOpenJobs',
title: () => t('settingsJobsTitle'),
desc: () => t('settingsJobsDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
),
},
{
id: 'sidechat',
title: () => t('sideChat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
// thread on mount); a thread switch from the header menu parks the
// target id for a deterministic `sidechat:<threadId>` reattach tab.
createTab: () => {
const threadId = consumeSidechatSeed()
if (threadId !== undefined) {
return {
tab: {
id: `sidechat:${threadId}`,
type: 'sidechat',
title: t('sideChat'),
meta: { threadId },
},
}
}
return {
tab: {
id: `sidechat:new-${crypto.randomUUID()}`,
type: 'sidechat',
title: t('sideChatUntitled'),
meta: { autoCreate: true },
},
}
},
// One tab per thread: an already-open thread focuses instead of
// duplicating; unbound fresh tabs never dedupe (each mints its own).
dedupeKey: (tab) => sidechatThreadIdOf(tab),
// Closing the tab releases the thread's live agent; the session and
// its history stay persisted (reopen from any thread's header menu).
onClose: (tab) => {
const threadId = sidechatThreadIdOf(tab)
if (threadId !== undefined) {
void api.sidechatDispose(threadId).catch(() => {})
}
},
component: ({ ctx, scope, tab, visible }) => (
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
),
},
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch, the
// bottom-panel first-expansion auto-terminal switch, and the custom
// font family/size rows render under this card in the Side card
// settings page (the host gates the toolset on the tools one
// independently; the font rows apply live to every terminal).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}, {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}, {
key: 'terminalShell',
type: 'text',
title: () => t('settingsShellTitle'),
desc: () => t('settingsShellDesc'),
placeholder: t('settingsShellPlaceholder'),
}, {
key: 'terminalShellArgs',
type: 'text',
title: () => t('settingsShellArgsTitle'),
desc: () => t('settingsShellArgsDesc'),
placeholder: t('settingsShellArgsPlaceholder'),
}, {
key: 'terminalFontFamily',
type: 'text',
title: () => t('settingsFontFamilyTitle'),
desc: () => t('settingsFontFamilyDesc'),
placeholder: t('settingsFontFamilyPlaceholder'),
}, {
key: 'terminalFontSize',
type: 'number',
title: () => t('settingsFontSizeTitle'),
desc: () => t('settingsFontSizeDesc'),
min: TERMINAL_FONT_SIZE_MIN,
max: TERMINAL_FONT_SIZE_MAX,
unit: 'px',
}],
},
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${terminalUuid()}`,
type: 'terminal',
title: options.terminalTitle?.() ?? t('terminal'),
},
// Keep the legacy counter advancing for compatibility with older
// persisted states; new ids no longer use it.
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
},
{
id: 'browser',
title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
// https off by default) render under this tab's row in the Side card
// settings page (the sandbox one is warned on).
settings: {
toggles: [{
key: 'browserNoSandbox',
title: () => t('settingsBrowserSandboxTitle'),
desc: () => t('settingsBrowserSandboxDesc'),
}, {
key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'),
}, {
key: 'browserInterceptHttp',
title: () => t('settingsBrowserHttpTitle'),
desc: () => t('settingsBrowserHttpDesc'),
}, {
key: 'browserInterceptHttps',
title: () => t('settingsBrowserHttpsTitle'),
desc: () => t('settingsBrowserHttpsDesc'),
}, {
key: 'browserAllowedLoopback',
type: 'text',
title: () => t('settingsBrowserLoopbackTitle'),
desc: () => t('settingsBrowserLoopbackDesc'),
placeholder: t('settingsBrowserLoopbackPlaceholder'),
}],
},
createTab: (state) => ({
tab: {
id: `browser:${state.nextBrowser}`,
type: 'browser',
title: t('browser'),
},
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'file-trace',
title: () => t('fileTrace'),
icon: (size: number) => <IconHistoryOutline16 size={size} />,
order: 55,
single: true,
badge: (ctx, scope) => {
const events = ctx.sessions.get(scope.sessionId)?.events
return events === undefined ? null : extractFileOps(events).length
},
component: (props) => <FileTraceTab {...props} />,
},
{
id: 'diff',
title: () => t('git'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
component: ({ scope, tab }) => (
tab.diff === undefined ? null
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
),
},
]
}