From db0ba3661004618881a594eb1e78a24e52622867 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 11:12:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(service):=20=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E4=BF=AE=E6=AD=A3=20=E2=80=94=20seed.title?= =?UTF-8?q?=20=E4=BC=98=E5=85=88=E3=80=81single=20=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E7=B3=96=E3=80=81available=20=E4=B8=89=E5=8F=82=E3=80=81?= =?UTF-8?q?=E5=8D=95=E8=B6=9F=E5=8C=B9=E9=85=8D=E3=80=81dedupe=20=E5=A7=94?= =?UTF-8?q?=E6=89=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.openTab 的 seed.title 优先于 descriptor.title(修复编辑器 tab 标题 从文件名变成 t('editor') 的回归);title 变为可选,descriptor.title 兜底 - TabDescriptor 新增 single?: boolean 语法糖(展开为 dedupeKey: () => id), dedupeKey 显式给出时优先 - available 签名升级为 (ctx, scope, state) 三参(设计文档 §5.2 的超集), Sidebar.buildNewTabOptions 与 terminal 配额检查适配 - matchFileViewer 从三趟全局遍历改为设计文档 §8.2 的单趟 per-descriptor 算法: priority 降序,每个 descriptor 先 detect(head 可用时)再 exts,[] 为 catch-all - applyDedupe 的落位逻辑委托给 state.openTabInActivePane(消除重复实现), 同时 id 安全网现在也覆盖 service.openTab 路径(同 id 打开聚焦既有 tab) - state.ts:删除零引用的 SIDEBAR_BUILTIN_TAB_TYPES;TERMINAL_LIMIT 移入 builtins.tsx(state 不再关心 terminal);openTab reducer 改名 openTabInActivePane - Sidebar.tsx 删除 TERMINAL_LIMIT/allLeaves 死 import;openDiffTab 补充 dedupeKey 一致性注释(其按 id 去重与 diff descriptor 的 dedupeKey 同规则) - tests/service.spec.ts 重写匹配/去重断言并新增 title/single/available 用例 --- src/client/EditorView.tsx | 1 + src/client/Sidebar.tsx | 12 ++-- src/client/builtins.tsx | 13 ++-- src/client/service.ts | 103 ++++++++++++++++-------------- src/client/state.ts | 41 ++++++------ tests/service.spec.ts | 130 +++++++++++++++++++++++++++++++++++--- tests/unit.spec.ts | 38 +++++------ 7 files changed, 231 insertions(+), 107 deletions(-) diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index 86f9e39..0cf29cd 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -261,6 +261,7 @@ export function EditorView(props: { ctx: Context; store: SidebarStore; scope: Se )} {load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, { ctx, store, scope, path, title, + viewerId: load.viewer.id, content: load.content, truncated: load.truncated, mediaUrl: load.mediaUrl, diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 39b0356..d72b3af 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -15,10 +15,10 @@ import clsx from 'clsx' import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { - agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, + agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, reconcileAgentTerminals, resizeSplit, setWidth, toggleExpanded, togglePanel, - TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, + type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' @@ -27,7 +27,7 @@ import { relativeTo } from './paths.ts' import { OrphanedTab } from './OrphanedTab.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' import { t } from './locales.ts' -import { api } from './api.ts' +import { api, type SessionScope } from './api.ts' import css from './sidebar.module.css' /** How many consecutive reconnect failures stop the agent-terminals push loop @@ -66,7 +66,7 @@ function TabContent(props: { /** 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[] { +function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] { const service = ctx.betterSidebar if (service === undefined) return [] return service.getTabs() @@ -75,7 +75,7 @@ function buildNewTabOptions(state: SidebarState, ctx: Context): NewTabOption[] { .map(d => ({ id: d.id, label: typeof d.title === 'function' ? d.title() : d.title, - disabled: !(d.available?.(state) ?? true), + disabled: !(d.available?.(ctx, scope, state) ?? true), icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, })) } @@ -433,7 +433,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
t('explorer'), icon: (size: number) => , order: 10, - dedupeKey: () => 'explorer', + single: true, component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( t('git'), icon: (size: number) => , order: 20, - dedupeKey: () => 'git', + single: true, component: ({ ctx, store, scope, onOpenDiff }) => ( t('subagent'), icon: (size: number) => , order: 30, - dedupeKey: () => 'subagent', + single: true, component: ({ ctx, scope, visible, onSubagentJump }) => ( t('terminal'), icon: (size: number) => , order: 40, - available: (state) => uiTerminalCount(state) < TERMINAL_LIMIT, + available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT, createTab: (state) => { const count = uiTerminalCount(state) if (count >= TERMINAL_LIMIT) return null diff --git a/src/client/service.ts b/src/client/service.ts index cf0401e..bd755a4 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -2,7 +2,7 @@ * 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 + * {@link ../context-types.ts}); consumers declare it in `inject` and call * `registerTab` / `registerFileViewer`, both returning a disposer that * cordis auto-invokes on fiber disposal (HMR-safe). * @@ -12,15 +12,17 @@ * - `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). + * `single: true` is sugar for `dedupeKey: () => id`. * - `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). + * - `matchFileViewer` walks descriptors in priority order (desc, stable): + * per descriptor it tries `detect` first (when `head` bytes are given), + * then `exts`; `exts: []` is a catch-all that matches any path. */ import type { ReactNode } from 'react' import type { Context } from '../context-types.ts' import { - activateTab, allLeaves, closeTab as closeTabReducer, firstLeaf, mapLeaf, + activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' import type { SessionScope } from './api.ts' @@ -52,14 +54,24 @@ export interface TabDescriptor { 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 + /** + * + menu disabled predicate (e.g. terminal at capacity). Receives the + * session scope and the live sidebar state (counts, expansions). + */ + available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean + /** + * Single-instance sugar: `true` is shorthand for `dedupeKey: () => id` + * (opening the tab focuses an existing one of the same type instead of + * creating a duplicate). An explicit `dedupeKey` always wins when both + * are given. Builtins: explorer/git/subagent use `single: true`. + */ + single?: 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). + * Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id` + * (openDiffTab mints change-derived ids). */ dedupeKey?: (tab: SidebarTab) => string | undefined /** @@ -87,6 +99,8 @@ export interface FileViewerProps { scope: SessionScope path: string title: string + /** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */ + viewerId: string /** fsRead text content (fetchStrategy='fsRead'). */ content?: string truncated?: boolean @@ -106,8 +120,8 @@ export interface FileViewerDescriptor { priority?: number fetchStrategy: FileFetchStrategy /** - * Content sniff (overrides exts when `head` bytes are available). - * The first descriptor (priority desc) whose `detect` returns true wins. + * Content sniff: when `head` bytes are available the descriptor's `detect` + * is consulted before its `exts` (per-descriptor, in priority order). */ detect?: (path: string, head: Uint8Array) => boolean /** fetchStrategy='custom' loader. */ @@ -123,10 +137,15 @@ export interface BetterSidebarService { 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). */ + /** Find a file viewer for a path (priority desc; detect first, then 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']; id?: string }): void + /** + * Open a tab (used by external tabs and the + menu). `title` overrides + * the descriptor's title when given (the editor tab shows the file name); + * when the descriptor provides `createTab` it mints the tab itself and + * `title`/`path`/`id` are ignored. + */ + openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void /** Close a tab by id. */ closeTab(tabId: string): void /** Subscribe to registry changes (register/dispose). */ @@ -194,29 +213,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe 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( + // Single pass in priority order (descending; stable for equal + // priorities — insertion order). Each descriptor gets first refusal in + // its own turn: `detect` (when head bytes are available) beats its own + // `exts`, and `exts: []` is a catch-all matching any path — so the + // catch-all `code` viewer (-100) only sees paths no higher-priority + // descriptor claimed. + for (const v of 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 + )) { + if (head !== undefined && v.detect !== undefined && v.detect(path, head)) return v + if (v.exts.length === 0 || v.exts.includes(ext)) return v } return undefined } - const openTab = (seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => { + const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => { store.reduce((state) => { const descriptor = tabs.get(seed.type) if (descriptor === undefined) return state @@ -230,7 +242,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const tab: SidebarTab = { id: seed.id ?? seed.type, type: seed.type, - title: typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title, + // A caller-provided title wins (the editor shows the file name); + // otherwise the descriptor's (possibly i18n) title is the default. + title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title), ...(seed.path !== undefined ? { path: seed.path } : {}), ...(seed.diff !== undefined ? { diff: seed.diff } : {}), } @@ -260,29 +274,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } /** - * Apply dedup: if a tab with the same dedupeKey exists, focus it instead. - * Mirrors the three branches the old `openTab` hardcoded (single/editor/diff). + * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the + * same type exists, focus it; otherwise land the tab through + * `openTabInActivePane` (the id safety net + active-pane landing are that + * reducer's job — not re-implemented here). + * `single: true` resolves to the id-key sugar when no explicit key is given. */ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { - const key = descriptor.dedupeKey?.(tab) + const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) + const key = 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) + const existing = leaf.tabs.find(t => t.type === tab.type && 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 - }), - } + return openTabInActivePane(state, tab) } /** Find which pane hosts a tab id ('' if none). */ diff --git a/src/client/state.ts b/src/client/state.ts index c6ce084..93ec300 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -12,18 +12,13 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' /** - * 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. + * Tab type identifier. Builtins register their ids (explorer / git / editor + * / terminal / subagent / diff) through the sidebar service; external + * plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so + * the registry stays open. */ 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 = | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean } @@ -75,7 +70,6 @@ export const PANEL_MIN = 280 export const PANEL_MAX = 640 export const PANEL_DEFAULT = 400 export const TAB_MAX_WIDTH = 160 -export const TERMINAL_LIMIT = 3 let nextIdCounter = 0 /** Unique pane/tab id within one state instance. */ @@ -310,14 +304,15 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string): } /** - * Open a tab (or focus its existing instance by id). Dedup strategies - * (single-instance, per-path, per-change) are owned by the tab descriptor - * through {@link BetterSidebarService.openTab} / `dedupeKey`; this reducer - * only handles the id-based safety net (reconcile/openDiffTab already - * check existence before calling) and lands the tab in the active pane - * (or the first pane when the active one is gone). + * Land a tab in the active pane (or focus its existing instance by id). + * Dedup strategies (single-instance, per-path, per-change) are owned by the + * tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`; + * this reducer only handles the id-based safety net (reconcile and + * openDiffTab already check existence before calling) and the landing + * itself — the service's dedupe path delegates here after its dedupeKey + * check misses. */ -export function openTab(state: SidebarState, tab: SidebarTab): SidebarState { +export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState { let targetId = state.activePane ?? firstLeaf(state.splits).id // A stale activePane (its pane was closed since) must not swallow the // open: fall back to the first pane instead of dropping the tab. @@ -373,6 +368,12 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState * already holds diff tabs (diff panes are sticky — repeated clicks stack * there); on the FIRST diff of a layout the source pane splits vertically so * the diff lands in a fresh pane below it ("默认在下半栏新增一个"). + * + * This is split-tree placement surgery, not registry dispatch: the diff tab + * descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance + * check below is exactly that rule — the two agree by construction (asserted + * in tests). Diff tabs minted by the Git view carry change-derived ids, so + * the id check is the per-change dedupe. * @returns the new state, with the diff pane active. */ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState { @@ -393,7 +394,7 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side // (A stale sourcePaneId — its pane closed meanwhile — degrades to the // regular open path instead of dropping the tab into an orphaned leaf.) if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) { - return openTab(state, tab) + return openTabInActivePane(state, tab) } const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false) return { ...state, splits: result.node, activePane: result.leafId } @@ -488,7 +489,7 @@ export function reconcileAgentTerminals( } } // Add tabs for new uuids (the agent created a terminal). They land in the - // active pane via openTab; the next reconcile is a no-op for them. + // active pane via openTabInActivePane; the next reconcile is a no-op for them. let next: SidebarState = { ...state, splits } for (const terminal of toAdd) { const tab: SidebarTab = { @@ -496,7 +497,7 @@ export function reconcileAgentTerminals( type: 'terminal', title: terminal.title, } - next = openTab(next, tab) + next = openTabInActivePane(next, tab) } return next } diff --git a/tests/service.spec.ts b/tests/service.spec.ts index d929d2f..50abd68 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -104,22 +104,44 @@ describe('matchFileViewer', () => { expect(service.matchFileViewer('x.txt')?.id).toBe('catchall') }) - it('detect overrides exts when head bytes are available', () => { + it('detect claims files the viewer would otherwise miss, at its priority', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + // by-magic does not match 'bin' by extension; only its detect (PNG magic) + // can claim it — and only when head bytes are available. + service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 5, fetchStrategy: 'fsRead', component: () => null }) + service.registerFileViewer({ + id: 'by-magic', + exts: ['mag'], + priority: 10, + fetchStrategy: 'fsRead', + detect: (_path, head) => head[0] === 0x89, + component: () => null, + }) + // No head: by-magic's exts miss, by-ext claims .bin. + expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext') + // Head with PNG magic: by-magic's detect fires at priority 10 before + // by-ext (5) is consulted. + expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic') + // Head without the magic: detect misses, by-ext claims .bin again. + expect(service.matchFileViewer('file.bin', new Uint8Array([0x00, 0x50]))?.id).toBe('by-ext') + }) + + it('priority decides first: a higher-priority exts match beats a lower-priority detect', () => { 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: [], + exts: ['mag'], 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') + // Per-descriptor walk: by-ext (priority 10) claims .bin before by-magic's + // detect (priority 5) is ever consulted — the design's priority-first rule. + expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext') }) it('returns undefined when no viewer matches and no catch-all', () => { @@ -148,7 +170,7 @@ describe('service.openTab dedupe', () => { expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1) }) - it('no dedupeKey opens a new tab each time', () => { + it('no dedupeKey opens a new tab for each distinct id', () => { const store = createSidebarStore() const service = createBetterSidebarService(store) service.registerTab({ @@ -157,13 +179,29 @@ describe('service.openTab dedupe', () => { component: () => null, }) store.setSession('s1') - service.openTab({ type: 'multi', title: 'Multi' }) - service.openTab({ type: 'multi', title: 'Multi' }) + service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' }) + service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' }) const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(l => l.tabs) expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2) }) + it('reopening with the same id focuses the existing tab (id safety net)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'multi', + title: 'Multi', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' }) + service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs) + expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1) + }) + it('createTab mints custom ids and patches state', () => { const store = createSidebarStore() const service = createBetterSidebarService(store) @@ -186,4 +224,78 @@ describe('service.openTab dedupe', () => { expect(tabs[1]!.id).toBe('counter:2') expect(state.nextTerminal).toBe(3) }) + + it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null }) + store.setSession('s1') + service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }) + const state = store.getSnapshot().state! + const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor') + expect(tab?.title).toBe('main.ts') + }) + + it('the descriptor title is the default when no title is given', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null }) + store.setSession('s1') + service.openTab({ type: 'plain' }) + const state = store.getSnapshot().state! + const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain') + expect(tab?.title).toBe('Plain') + }) + + it('single: true dedupes like dedupeKey: () => id (sugar)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null }) + store.setSession('s1') + service.openTab({ type: 'singleton' }) + service.openTab({ type: 'singleton', id: 'singleton:extra' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton') + expect(tabs).toHaveLength(1) + }) + + it('an explicit dedupeKey wins over single: true', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'multi', + title: 'Multi', + single: true, + dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'multi', id: 'multi:1' }) + service.openTab({ type: 'multi', id: 'multi:2' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi') + expect(tabs).toHaveLength(2) + service.openTab({ type: 'multi', id: 'multi:1' }) + const state2 = store.getSnapshot().state! + const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi') + expect(tabs2).toHaveLength(2) + }) + + it('available receives ctx, scope and the live state (superset signature)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + const seen: unknown[] = [] + service.registerTab({ + id: 'gated', + title: 'Gated', + available: (ctx, scope, state) => { + seen.push([ctx, scope, state]) + return true + }, + component: () => null, + }) + store.setSession('s1') + expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true) + expect(seen).toHaveLength(1) + }) }) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index c16fdc2..4001499 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, + moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' @@ -217,22 +217,22 @@ describe('sidebar state', () => { it('opens tabs into the active pane and dedupes by id (safety net)', () => { let s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - s = openTab(s, gitTab) + s = openTabInActivePane(s, gitTab) expect(s.splits.kind).toBe('leaf') expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // Reopening with the SAME id focuses the existing tab instead of duplicating. - const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' }) + const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // A different id opens a new tab (type-level dedupe is the service's job). - const after2 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' }) + const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' }) expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3) }) it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => { let s = state() const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id - s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) - const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) + s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) + const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2']) }) @@ -246,7 +246,7 @@ describe('sidebar state', () => { it('first diff splits the source pane vertically (diff below)', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTab(s, gitTab) + const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) expect(after.splits.kind).toBe('split') @@ -262,7 +262,7 @@ describe('sidebar state', () => { it('reopening the same diff focuses its existing tab', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTab(s, gitTab) + const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts')) @@ -276,7 +276,7 @@ describe('sidebar state', () => { it('subsequent diffs stack into the existing diff pane', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTab(s, gitTab) + const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678')) @@ -341,10 +341,10 @@ describe('sidebar state', () => { it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => { let s = state() - s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) + s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // Reopening (e.g. the auto-activation effect) focuses the existing tab. - const after = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) + const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1) @@ -375,7 +375,7 @@ describe('sidebar state', () => { const paneB = split.children[1] as { id: string; tabs: { id: string }[] } const tabId = paneA.tabs[0]!.id // 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。 - s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' }) + s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right') const after = s.splits as Extract // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 @@ -401,7 +401,7 @@ describe('sidebar state', () => { it('dragging a tab back onto its own pane center reorders it', () => { let s = state() - s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' }) + s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) const leaf = s.splits as { id: string; tabs: { id: string }[] } const first = leaf.tabs[0]!.id s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center') @@ -419,7 +419,7 @@ describe('sidebar state', () => { const explorerId = paneA.tabs[0]!.id // paneA gets a terminal; the explorer moves to paneB; closing the // terminal empties paneA, which is removed, promoting paneB. - s = openTab(s, { id: 't', type: 'terminal', title: 'Terminal 1' }) + s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' }) s = moveTab(s, paneA.id, explorerId, paneB.id) s = activateTab(s, paneA.id, 't') s = closeTab(s, paneA.id, 't') @@ -468,7 +468,7 @@ describe('sidebar state', () => { s = closeTab(s, target.id, explorerId) expect(tabOpenIn(s, explorerId)).toBe(false) // A terminal tab added later is open too. - s = openTab(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) + s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) expect(tabOpenIn(s, 'terminal:9')).toBe(true) }) }) @@ -511,7 +511,7 @@ describe('agent terminal reconciliation', () => { it('does not touch non-agent terminal tabs', () => { let s = makeDefaultState(400, true) - s = openTab(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' }) + s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' }) s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }]) expect(tabOpenIn(s, 'terminal:1')).toBe(true) expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) @@ -688,7 +688,7 @@ describe('persisted state sanitization', () => { expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2) expect(clean!.activePane).toBe(leaves[1]!.id) // And an open must land in exactly one pane of the healed tree. - const opened = openTab(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) + const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts')) expect(owners).toHaveLength(1) }) @@ -698,13 +698,13 @@ describe('persisted state sanitization', () => { const paneA = allLeaves(s.splits)[0]!.id const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id s = closeTab(s, paneA, explorerTab) - s = openTab(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) + s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false) s = { ...s, splits: split.node, activePane: paneA } // Closing the editor empties paneA; the pane is removed but activePane // still points at it. The next open must land in the surviving pane. s = closeTab(s, paneA, 'editor:/a.ts') - s = openTab(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' }) + s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' }) const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts')) expect(owners).toHaveLength(1) expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true) From 0467101faf517262ea8c64e1760f20d15ccf8d4a Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 11:16:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(editor):=20=E6=8B=86=E5=88=86=20Editor?= =?UTF-8?q?Host/TextEditor=20=E2=80=94=20code/markdown=20=E6=88=90?= =?UTF-8?q?=E4=B8=BA=E6=B3=A8=E5=86=8C=20viewer=EF=BC=8Cfs.read=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20head=20=E5=AD=97=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 src/client/EditorHost.tsx:'editor' tab 组件。matchFileViewer → 按 fetchStrategy 分发(纯逻辑在 editor-load.ts)→ 渲染 viewer.component; binary 无匹配时渲染共享 BinaryDownload。标题栏保留在 host - 新 src/client/TextEditor.tsx:code/markdown 的 viewer 组件(CodeMirror + 保存/脏点/截断横幅 + md 预览/编辑切换),内容由 host 经 fsRead 传入, 自身不再 fetch;按 viewerId === 'markdown' 分支(不再解析扩展名) - 新 src/client/editor-load.ts:planFirstMatch / planFsReadOutcome / decodeHead 纯函数,策略分发可单测 - 新 src/client/binary-download.tsx:共享下载兜底组件(viewer 注册与 host fallback 同一 UI) - host src/index.ts:fs.read 二进制响应增加 head 字段(base64,前 4KB, 唯一 host 触碰,设计文档 detect 机制所需);客户端 FsBinaryResult 同步 - builtins:注册 8 个 viewer(新增 markdown exts md/markdown、code catch-all -100);binary-download 增加 NUL detect + 复用 BinaryDownload - 删除 src/client/EditorView.tsx(职责一分为二);陈旧注释同步 - 新增 tests/editor-load.spec.ts(12 用例) --- src/client/EditorHost.tsx | 101 ++++++++++++ src/client/EditorView.tsx | 285 --------------------------------- src/client/TextEditor.tsx | 193 ++++++++++++++++++++++ src/client/api.ts | 5 +- src/client/binary-download.tsx | 23 +++ src/client/builtins.tsx | 48 +++--- src/client/cm-themes.ts | 2 +- src/client/editor-load.ts | 92 +++++++++++ src/client/office-types.ts | 4 +- src/client/office-view.tsx | 2 +- src/index.ts | 24 ++- tests/editor-load.spec.ts | 100 ++++++++++++ 12 files changed, 565 insertions(+), 314 deletions(-) create mode 100644 src/client/EditorHost.tsx delete mode 100644 src/client/EditorView.tsx create mode 100644 src/client/TextEditor.tsx create mode 100644 src/client/binary-download.tsx create mode 100644 src/client/editor-load.ts create mode 100644 tests/editor-load.spec.ts diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx new file mode 100644 index 0000000..6e1c202 --- /dev/null +++ b/src/client/EditorHost.tsx @@ -0,0 +1,101 @@ +/** + * The editor tab host: resolves a file's previewer through the sidebar + * registry (`matchFileViewer`), fetches bytes per the matched viewer's + * fetch strategy, and renders its component — or the shared download pane + * when nothing can render the file. The header shows the file title; the + * editable code/markdown viewers render their own toolbar below it. + * + * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in + * editor-load.ts); this component only wires it to the host APIs. + */ +import { useEffect, useState } from 'react' +import { createElement } from 'react' +import type { Context } from '../context-types.ts' +import { api, mediaUrl, type SessionScope } from './api.ts' +import { BinaryDownload } from './binary-download.tsx' +import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' +import { t } from './locales.ts' +import type { FileViewerDescriptor } from './service.ts' +import type { SidebarStore } from './state.ts' +import css from './sidebar.module.css' + +type EditorLoad = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } + | { status: 'binary' } + +export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { + const { ctx, store, scope, path, title } = props + const [load, setLoad] = useState({ status: 'loading' }) + + useEffect(() => { + let cancelled = false + setLoad({ status: 'loading' }) + const mediaUrlOf = (): string => mediaUrl(scope, path) + const apply = (action: EditorLoadAction): void => { + if (cancelled) return + switch (action.kind) { + case 'binary': + setLoad({ status: 'binary' }) + return + case 'render': + setLoad({ + status: 'ready', + viewer: action.viewer, + content: action.content, + truncated: action.truncated, + mediaUrl: action.mediaUrl, + customData: action.customData, + }) + return + case 'customLoad': + void action.viewer.load?.(path, scope).then((data) => { + if (cancelled) return + setLoad({ status: 'ready', viewer: action.viewer, customData: data }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + case 'fetchFsRead': + api.fsRead(scope, path).then((result) => { + if (cancelled) return + // Binary reads carry the head bytes for the detect re-match. + const outcome = planFsReadOutcome(action.viewer, { + binary: result.kind === 'binary', + content: result.kind === 'text' ? result.content : '', + truncated: result.truncated, + head: result.kind === 'binary' ? result.head : undefined, + }, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf) + apply(outcome) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + } + } + apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) + return () => { cancelled = true } + }, [scope.sessionId, scope.cwd, path, ctx]) + + return ( +
+
+ {title} +
+ {load.status === 'loading' &&
{t('loading')}
} + {load.status === 'error' &&
{load.message}
} + {load.status === 'binary' && } + {load.status === 'ready' && createElement(load.viewer.component, { + ctx, store, scope, path, title, + viewerId: load.viewer.id, + content: load.content, + truncated: load.truncated, + mediaUrl: load.mediaUrl, + customData: load.customData, + })} +
+ ) +} diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx deleted file mode 100644 index 0cf29cd..0000000 --- a/src/client/EditorView.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * The file editor tab: a CodeMirror 6 editor with line wrapping and syntax - * highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save, - * image viewing through the media route, and a plain notice for binary - * files. Markdown files open in rendered preview with a preview/edit toggle: - * edit mode shows the source in the editor, preview renders the live draft, - * and unsaved edits survive the toggle. Reads cap at the host's 512KB bound - * (a banner marks truncation). - */ -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 type { FileViewerDescriptor } from './service.ts' -import type { SidebarStore } from './state.ts' -import css from './sidebar.module.css' - -const MD_EXT = ['.md', '.markdown'] - -type EditorLoad = - | { status: 'loading' } - | { status: 'error'; message: string } - | { 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: { 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. */ - const [draft, setDraft] = useState(null) - const [dirty, setDirty] = useState(false) - const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') - const hostRef = useRef(null) - const viewRef = useRef(null) - const savingRef = useRef(false) - /** The theme compartment of the current view (reconfigured on scheme flip). */ - const themeCompRef = useRef(null) - /** The app's resolved color scheme; the editor re-themes in place on flips. */ - const [dark, setDark] = useState(() => isDarkScheme()) - - useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) - - useEffect(() => { - let cancelled = false - setLoad({ status: 'loading' }) - setMode('preview') - setDraft(null) - setDirty(false) - setSaveState('idle') - const ext = extOfPath(path) - // 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') { - setLoad({ status: 'binary' }) - return - } - setLoad({ - status: 'ready', - kind: MD_EXT.includes(ext) ? 'md' : 'text', - content: result.content, - truncated: result.truncated, - }) - }).catch((error: unknown) => { - if (cancelled) return - setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) - }) - return () => { cancelled = true } - }, [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 - // listener. For markdown the view stays mounted while previewing (hidden), - // so unsaved edits survive the preview/edit toggle. The theme + syntax - // 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.viewer !== undefined || (load.kind !== 'text' && load.kind !== 'md')) return - const host = hostRef.current - if (host === null) return - const language = languageForPath(path) - const themeComp = new CmThemeCompartment() - themeCompRef.current = themeComp - const state = EditorState.create({ - doc: load.content, - extensions: [ - CodeMirrorView.lineWrapping, - lineNumbers(), - history(), - EditorState.tabSize.of(2), - CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), - cmSurfaceTheme, - themeComp.of(dark), - ...(language !== null ? [language] : []), - CodeMirrorView.updateListener.of((update) => { - if (update.docChanged) { - setDraft(update.state.doc.toString()) - setDirty(true) - } - }), - keymap.of([ - { - key: 'Mod-s', - preventDefault: true, - run: () => { save(); return true }, - }, - ...defaultKeymap, - ...historyKeymap, - ]), - ], - }) - const view = new CodeMirrorView({ state, parent: host }) - viewRef.current = view - return () => { - view.destroy() - viewRef.current = null - themeCompRef.current = null - } - // The keymap's save() reads live refs; scope/path are stable for a - // tab's lifetime, and the dark flip is handled by the reconfigure - // effect below (recreating the view here would drop the draft). - }, [load]) - - // Scheme flip: re-theme in place (the compartment holds only the - // scheme-dependent extensions; everything else is untouched). - useEffect(() => { - const view = viewRef.current - const themeComp = themeCompRef.current - if (view === null || themeComp === null) return - view.dispatch({ effects: themeComp.reconfigure(dark) }) - }, [dark]) - - // The editor may have been display:none while previewing; re-measure when - // it becomes visible again (CodeMirror sizes itself on reveal). - useEffect(() => { - if (mode === 'edit') viewRef.current?.requestMeasure() - }, [mode]) - - const save = (): void => { - const view = viewRef.current - if (view === null || savingRef.current) return - savingRef.current = true - setSaveState('saving') - api.fsWrite(scope, path, view.state.doc.toString()).then(() => { - savingRef.current = false - setDraft(null) - setDirty(false) - setSaveState('saved') - }).catch(() => { - savingRef.current = false - setSaveState('failed') - }) - } - - const previewable = load.status === 'ready' && load.kind === 'md' - const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md') - const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' - - return ( -
-
- {title} - {previewable && ( -
- - -
- )} - {dirty && } - {editable && ( - - )} - {saveLabel !== '' && {saveLabel}} -
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} - {load.status === 'binary' && ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- )} - {editable && ( - <> - {load.truncated && mode === 'edit' &&
{t('truncation')}
} -
- - )} - {load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, { - ctx, store, scope, path, title, - viewerId: load.viewer.id, - content: load.content, - truncated: load.truncated, - mediaUrl: load.mediaUrl, - customData: load.customData, - })} - {previewable && mode === 'preview' && ( -
- -
- )} -
- ) -} - -/** The lowercased file extension of a path ('' when none). */ -function extOfPath(path: string): string { - const at = path.lastIndexOf('.') - if (at === -1) return '' - const base = path.slice(at).toLowerCase() - return base.includes('/') || base.includes('\\') ? '' : base -} diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx new file mode 100644 index 0000000..ca72853 --- /dev/null +++ b/src/client/TextEditor.tsx @@ -0,0 +1,193 @@ +/** + * The code/markdown file viewer: a CodeMirror 6 editor with line wrapping, + * syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S + * save, and a preview/edit toggle for markdown files. Registered as the + * `code` (catch-all) and `markdown` built-in viewers; the editor tab host + * fetches the content through the fsRead strategy and passes it in props, + * so this component never fetches or dispatches — it only edits. + * + * The toolbar (mode toggle / dirty dot / save / status) renders as its own + * row below the host's title bar, VSCode-style. + */ +import { 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 { api } 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 type { FileViewerProps } from './service.ts' +import css from './sidebar.module.css' + +/** Previewable files (rendered output vs source editing). */ +type ViewMode = 'preview' | 'edit' + +export function TextEditor(props: FileViewerProps) { + const { scope, path, viewerId, content, truncated } = props + const [mode, setMode] = useState('preview') + /** The editor's current text (null while clean); preview renders this. */ + const [draft, setDraft] = useState(null) + const [dirty, setDirty] = useState(false) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') + const hostRef = useRef(null) + const viewRef = useRef(null) + const savingRef = useRef(false) + /** The theme compartment of the current view (reconfigured on scheme flip). */ + const themeCompRef = useRef(null) + /** The app's resolved color scheme; the editor re-themes in place on flips. */ + const [dark, setDark] = useState(() => isDarkScheme()) + + useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) + + // A new file (tab switch) starts clean: fresh preview mode, no draft. + useEffect(() => { + setMode('preview') + setDraft(null) + setDirty(false) + setSaveState('idle') + }, [content]) + + // Create the CodeMirror editor once the content is loaded. The view owns + // the document; React only tracks dirty/draft state through the update + // listener. For markdown the view stays mounted while previewing (hidden), + // so unsaved edits survive the preview/edit toggle. The theme + syntax + // colors live in a compartment so a scheme flip reconfigures only that + // part — the document, undo history and scroll position survive. + useEffect(() => { + if (content === undefined) return + const host = hostRef.current + if (host === null) return + const language = languageForPath(path) + const themeComp = new CmThemeCompartment() + themeCompRef.current = themeComp + const state = EditorState.create({ + doc: content, + extensions: [ + CodeMirrorView.lineWrapping, + lineNumbers(), + history(), + EditorState.tabSize.of(2), + CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), + cmSurfaceTheme, + themeComp.of(dark), + ...(language !== null ? [language] : []), + CodeMirrorView.updateListener.of((update) => { + if (update.docChanged) { + setDraft(update.state.doc.toString()) + setDirty(true) + } + }), + keymap.of([ + { + key: 'Mod-s', + preventDefault: true, + run: () => { save(); return true }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), + ], + }) + const view = new CodeMirrorView({ state, parent: host }) + viewRef.current = view + return () => { + view.destroy() + viewRef.current = null + themeCompRef.current = null + } + // The keymap's save() reads live refs; scope/path are stable for a + // tab's lifetime, and the dark flip is handled by the reconfigure + // effect below (recreating the view here would drop the draft). + }, [content, path]) + + // Scheme flip: re-theme in place (the compartment holds only the + // scheme-dependent extensions; everything else is untouched). + useEffect(() => { + const view = viewRef.current + const themeComp = themeCompRef.current + if (view === null || themeComp === null) return + view.dispatch({ effects: themeComp.reconfigure(dark) }) + }, [dark]) + + // The editor may have been display:none while previewing; re-measure when + // it becomes visible again (CodeMirror sizes itself on reveal). + useEffect(() => { + if (mode === 'edit') viewRef.current?.requestMeasure() + }, [mode]) + + const save = (): void => { + const view = viewRef.current + if (view === null || savingRef.current) return + savingRef.current = true + setSaveState('saving') + api.fsWrite(scope, path, view.state.doc.toString()).then(() => { + savingRef.current = false + setDraft(null) + setDirty(false) + setSaveState('saved') + }).catch(() => { + savingRef.current = false + setSaveState('failed') + }) + } + + const markdown = viewerId === 'markdown' + const editable = content !== undefined + const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' + + return ( + <> +
+ {markdown && ( +
+ + +
+ )} + {dirty && } + {editable && ( + + )} + {saveLabel !== '' && {saveLabel}} +
+ {editable && ( + <> + {truncated === true && mode === 'edit' &&
{t('truncation')}
} +
+ + )} + {markdown && mode === 'preview' && ( +
+ +
+ )} + + ) +} diff --git a/src/client/api.ts b/src/client/api.ts index 5583565..8fd943c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -54,8 +54,9 @@ export interface GitLogEntry { /** Text read result. */ export interface FsTextResult { kind: 'text'; content: string; truncated: boolean } -/** Binary read result (no content; images load through the media route). */ -export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean } +/** Binary read result (no content; images load through the media route). + * `head` carries the first bytes (base64) for viewer detect sniffing. */ +export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response diff --git a/src/client/binary-download.tsx b/src/client/binary-download.tsx new file mode 100644 index 0000000..71ea3ef --- /dev/null +++ b/src/client/binary-download.tsx @@ -0,0 +1,23 @@ +/** + * The "no preview — download instead" pane: shown by the editor host when no + * viewer can render a file (binary without a registered renderer, or the + * `binary-download` strategy) and registered as the `binary-download` viewer + * component so the declarative route and the host fallback share one UI. + */ +import type { ReactNode } from 'react' +import type { SessionScope } from './api.ts' +import { downloadUrl } from './api.ts' +import { t } from './locales.ts' +import css from './sidebar.module.css' + +export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode { + const { scope, path } = props + return ( +
+ {t('binaryNoPreview')} + + {t('downloadToView')} + +
+ ) +} diff --git a/src/client/builtins.tsx b/src/client/builtins.tsx index cc1b044..229bb68 100644 --- a/src/client/builtins.tsx +++ b/src/client/builtins.tsx @@ -1,22 +1,24 @@ /** * Built-in tab types and file viewers: the plugin registers its own * built-in pages (explorer / git / terminal / subagent / editor / diff) - * and file previewers (image / pdf / docx / xlsx / pptx / binary-download) - * through the same {@link BetterSidebarService} external plugins use — - * eating its own dogfood. Code/Markdown editing stays inlined in - * {@link EditorView} as the catch-all fallback (no matching viewer → - * fsRead + CodeMirror/MarkdownText). + * and file previewers (image / pdf / docx / xlsx / pptx / markdown / code / + * binary-download) through the same {@link BetterSidebarService} external + * plugins use — eating its own dogfood. Code/markdown are registered + * viewers like any other (fsRead strategy, {@link TextEditor}); the + * `binary-download` viewer sniffs NUL bytes via `detect` for unknown + * binaries and serves doc/xls/ppt by extension. */ import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { allLeaves, isAgentTabId, type SidebarState } from './state.ts' -import { downloadUrl, mediaUrl } from './api.ts' +import { mediaUrl } from './api.ts' import { t } from './locales.ts' import { openSidebarFile } from './intercept.tsx' import { ExplorerView } from './ExplorerView.tsx' -import { EditorView } from './EditorView.tsx' +import { EditorHost } from './EditorHost.tsx' +import { TextEditor } from './TextEditor.tsx' import { TerminalView } from './TerminalView.tsx' import { GitView } from './GitView.tsx' import { DiffTab } from './DiffTab.tsx' @@ -24,6 +26,7 @@ import { SubagentView } from './SubagentView.tsx' import { DocxView, XlsxView } from './office-view.tsx' import { PdfView } from './PdfView.tsx' import { PptxView } from './PptxView.tsx' +import { BinaryDownload } from './binary-download.tsx' import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx' import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts' import css from './sidebar.module.css' @@ -49,7 +52,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] { hidden: true, dedupeKey: (tab) => tab.path, component: ({ ctx, store, scope, tab }) => ( - + ), }, { @@ -135,7 +138,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] { ] } -/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */ +/** The 8 built-in file viewer descriptors — every preview surface is a registered viewer. */ const builtinViewers: readonly FileViewerDescriptor[] = [ { id: 'image', @@ -179,19 +182,28 @@ const builtinViewers: readonly FileViewerDescriptor[] = [ ), }, + { + id: 'markdown', + exts: ['md', 'markdown'], + fetchStrategy: 'fsRead', + component: (props) => , + }, + { + id: 'code', + exts: [], + priority: -100, + fetchStrategy: 'fsRead', + component: (props) => , + }, { id: 'binary-download', exts: ['doc', 'xls', 'ppt'], priority: -50, fetchStrategy: 'binary-download', - component: ({ scope, path }) => ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- ), + // NUL probe: a file whose head bytes contain a NUL is binary — claimed + // before the catch-all code viewer on the head re-match. + detect: (_path, head) => head.includes(0), + component: ({ scope, path }) => , }, ] @@ -199,7 +211,7 @@ const builtinViewers: readonly FileViewerDescriptor[] = [ * 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). + * (EditorHost reads `ctx.betterSidebar` for file-viewer matching). */ export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void { const disposers: (() => void)[] = [] diff --git a/src/client/cm-themes.ts b/src/client/cm-themes.ts index f739179..673c464 100644 --- a/src/client/cm-themes.ts +++ b/src/client/cm-themes.ts @@ -5,7 +5,7 @@ * values, and those come from the same designed palettes the app's code * surfaces use — the one-dark family for dark, the one-light family for * light. The scheme flip reconfigures these via a compartment (see - * EditorView), so the document, undo history and scroll survive re-theming. + * TextEditor), so the document, undo history and scroll survive re-theming. */ import { Compartment } from '@codemirror/state' import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' diff --git a/src/client/editor-load.ts b/src/client/editor-load.ts new file mode 100644 index 0000000..96249ca --- /dev/null +++ b/src/client/editor-load.ts @@ -0,0 +1,92 @@ +/** + * Pure editor-load planning: the decision logic the editor host needs to + * turn a matched file viewer + a host fs.read result into a render action. + * Kept dependency-free (no React, no fetch) so the strategy dispatch is + * unit-testable and the wire contract (head bytes, binary flag) is pinned. + * + * The host flow this module drives: + * 1. `matchFileViewer(path)` picks a viewer by extension/priority. + * 2. `planFirstMatch` dispatches its fetchStrategy. + * 3. An fsRead viewer fetches through the host; `planFsReadOutcome` + * decides what to do with the result — including the head-based + * re-match that lets a `detect` viewer claim a binary the extension + * match could not see (the builtin NUL probe on `binary-download`). + */ +import type { FileViewerDescriptor } from './service.ts' + +/** One host fs.read result (mirror of the wire; `head` present when binary). */ +export interface FsReadOutcome { + binary: boolean + content: string + truncated: boolean + /** base64 of the first bytes (present on binary reads; sniffing material). */ + head?: string +} + +/** What the editor host should do next. */ +export type EditorLoadAction = + /** No renderer: show the download UI. */ + | { kind: 'binary' } + /** Render `viewer`'s component with the carried payload. */ + | { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } + /** Fetch the file through the host (fsRead strategy). */ + | { kind: 'fetchFsRead'; viewer: FileViewerDescriptor } + /** Call the viewer's load() and render with its return value. */ + | { kind: 'customLoad'; viewer: FileViewerDescriptor } + +/** Decode the host's base64 head bytes into the sniffing buffer. */ +export function decodeHead(headBase64: string): Uint8Array { + const binary = atob(headBase64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return bytes +} + +/** + * Dispatch one matched viewer's fetchStrategy. A missing viewer or a + * `binary-download` strategy both mean "no client-side renderer" → the + * download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none` + * strategies (pure, but scope-bound — injected by the host). + */ +export function planFirstMatch( + viewer: FileViewerDescriptor | undefined, + mediaUrlOf: () => string, +): EditorLoadAction { + if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' } + switch (viewer.fetchStrategy) { + case 'mediaUrl': + case 'none': + return { kind: 'render', viewer, mediaUrl: mediaUrlOf() } + case 'custom': + return { kind: 'customLoad', viewer } + case 'fsRead': + return { kind: 'fetchFsRead', viewer } + } +} + +/** + * Decide what an fsRead result means for the editor. + * - Text: the first match stands (content is valid for any fsRead viewer). + * - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g. + * a plugin sniffing a binary format) may claim the file. `custom` viewers + * load their own bytes; `mediaUrl`/`none` viewers render the media route; + * an fsRead viewer or nothing cannot render binary → download UI. + */ +export function planFsReadOutcome( + viewer: FileViewerDescriptor, + result: FsReadOutcome, + rematch: (head: Uint8Array) => FileViewerDescriptor | undefined, + mediaUrlOf: () => string, +): EditorLoadAction { + if (!result.binary) { + return { kind: 'render', viewer, content: result.content, truncated: result.truncated } + } + const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head)) + if (claimed !== undefined && claimed.fetchStrategy === 'custom') { + return { kind: 'customLoad', viewer: claimed } + } + if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) { + return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() } + } + return { kind: 'binary' } +} diff --git a/src/client/office-types.ts b/src/client/office-types.ts index 6440cc3..93a35df 100644 --- a/src/client/office-types.ts +++ b/src/client/office-types.ts @@ -1,6 +1,6 @@ /** * Office file type dispatch — pure functions mapping file extensions to - * preview kinds. Kept separate from {@link EditorView} so the mapping is + * preview kinds. Kept separate from the editor host so the mapping is * unit-testable without mounting React or pulling in docx-preview / Univer. * * The host side never inspects Office extensions: the binary NUL probe in @@ -36,7 +36,7 @@ export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null /** * Map a lowercased extension (with leading dot) to its preview kind. * - * @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`, + * @param ext - the path extension (with leading dot) (`.docx`, * `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none. */ export function officeKindForExt(ext: string): OfficeKind { diff --git a/src/client/office-view.tsx b/src/client/office-view.tsx index 5948cfe..3766aa0 100644 --- a/src/client/office-view.tsx +++ b/src/client/office-view.tsx @@ -234,7 +234,7 @@ export function XlsxView(props: OfficeViewProps): JSX.Element { /** * The shared error / fallback affordance: the failure reason plus a download * link, so the user always has a path to the file. Used by both DocxView and - * XlsxView, and matches the binary placeholder's download link in EditorView. + * XlsxView, and matches the binary placeholder download link. */ function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element { const { scope, path, message } = props diff --git a/src/index.ts b/src/index.ts index 274720c..747455c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,8 +123,19 @@ async function resolveGitPath(cwd: string, raw: string): Promise { return requireAbsolute(join(root, raw)) } -/** Text read of a file with the size cap; binary detection via NUL probe. */ -async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> { +/** How many leading bytes a binary read returns for client-side detect sniffing. */ +const READ_HEAD_LIMIT = 4096 + +/** Text read of a file with the size cap; binary detection via NUL probe. + * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64) + * so the client can re-match viewers by content (`detect`). */ +async function readText(path: string, readLimit: number): Promise<{ + content: string + truncated: boolean + binary: boolean + size: number + head?: string +}> { const info = await stat(path).catch((error: unknown) => { throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) }) @@ -141,7 +152,10 @@ async function readText(path: string, readLimit: number): Promise<{ content: str const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) const slice = buffer.subarray(0, bytesRead) const binary = slice.includes(0) - return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size } + const head = binary + ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64') + : undefined + return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head } } finally { await handle.close() } @@ -194,8 +208,8 @@ function buildApi( // Relative paths are git-derived (status/diff report repo-root-relative // names; the untracked diff view reads the file through this route). const path = await resolveGitPath(cwd, requireString(payload, 'path')) - const { content, truncated, binary, size } = await readText(path, resolved.readLimit) - if (binary) return { kind: 'binary', size, truncated } + const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) + if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { diff --git a/tests/editor-load.spec.ts b/tests/editor-load.spec.ts new file mode 100644 index 0000000..c4afb2e --- /dev/null +++ b/tests/editor-load.spec.ts @@ -0,0 +1,100 @@ +/** + * Tests for the editor-load planning: the pure strategy dispatch the + * editor host runs (planFirstMatch / planFsReadOutcome / decodeHead). + */ +import { describe, expect, it } from 'vitest' +import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts' +import type { FileViewerDescriptor } from '../src/client/service.ts' + +const viewer = (over: Partial): FileViewerDescriptor => ({ + id: 'v', + exts: [], + fetchStrategy: 'fsRead', + component: () => null, + ...over, +}) + +describe('planFirstMatch', () => { + it('no viewer → binary (download UI)', () => { + expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('binary-download strategy → binary', () => { + const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('mediaUrl / none → render with the media URL', () => { + const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' }) + expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' }) + expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render') + }) + + it('custom → customLoad (the viewer loads its own bytes)', () => { + const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v }) + }) + + it('fsRead → fetchFsRead', () => { + const v = viewer({ id: 'code', fetchStrategy: 'fsRead' }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v }) + }) +}) + +describe('planFsReadOutcome', () => { + const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => { + // The builtin binary-download NUL probe shape. + if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' }) + return undefined + } + + it('text result renders with content, same viewer', () => { + const v = viewer({ id: 'code' }) + const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m') + expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true }) + }) + + it('binary without head bytes → binary', () => { + expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('binary whose NUL head re-matches nothing renderable → binary', () => { + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes + rematch, + () => '/m', + ) + // binary-download claims it — still the download UI. + expect(action).toEqual({ kind: 'binary' }) + }) + + it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => { + const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) }) + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, + () => sniffer, + () => '/m', + ) + expect(action).toEqual({ kind: 'customLoad', viewer: sniffer }) + }) + + it('binary re-matched to a mediaUrl viewer → render with the media URL', () => { + const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' }) + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, + () => media, + () => '/m?x=1', + ) + expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' }) + }) +}) + +describe('decodeHead', () => { + it('decodes base64 to the exact bytes', () => { + const head = decodeHead('AP//AA==') + expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00]) + }) +}) From 1b24bc8ff2d6e7305f974002817f1d3f956deb1d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 11:22:28 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(builtins):=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=87=8D=E7=BB=84=E4=B8=BA=20builtins/{tabs,viewers,index}?= =?UTF-8?q?=EF=BC=8C=E6=B8=85=E7=90=86=E6=AD=BB=E4=BB=A3=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=E5=86=85=E8=81=94=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 src/client/builtins/ 目录:tabs.tsx(6 个 tab descriptor + TERMINAL_LIMIT/uiTerminalCount,terminal 配额知识离开 state.ts)、 viewers.tsx(8 个 viewer descriptor)、index.ts(registerBuiltins 聚合 注册 + 幂等 disposer);删除旧 builtins.tsx - icons.tsx:删除零调用者的 tabTypeIcon 及多余 import(图标已归 descriptor.icon,映射随 tab 类型一起注册化) - OrphanedTab:内联 style → sidebar.module.css 新增 .orphanedType 类 - 新增 tests/builtins.spec.ts(6 tab + 8 viewer 注册断言、code catch-all、 binary-download NUL detect、office 优先级、disposer 幂等) - 新增 tests/orphaned-tab.spec.tsx(sanitize 保留未注册类型 tab、 OrphanedTab 占位渲染);tests/browser-globals.ts 供拉入 xterm/ CodeMirror 的 spec 首个 import(模块求值期需要 self/document/navigator) - service.spec 补 openDiffTab 与 diff dedupeKey 一致性用例 --- src/client/OrphanedTab.tsx | 2 +- src/client/builtins.tsx | 229 -------------------------------- src/client/builtins/index.ts | 33 +++++ src/client/builtins/tabs.tsx | 127 ++++++++++++++++++ src/client/builtins/viewers.tsx | 86 ++++++++++++ src/client/icons.tsx | 23 +--- src/client/index.tsx | 2 +- src/client/sidebar.module.css | 9 ++ tests/browser-globals.ts | 59 ++++++++ tests/builtins.spec.ts | 102 ++++++++++++++ tests/orphaned-tab.spec.tsx | 87 ++++++++++++ tests/service.spec.ts | 24 +++- 12 files changed, 531 insertions(+), 252 deletions(-) delete mode 100644 src/client/builtins.tsx create mode 100644 src/client/builtins/index.ts create mode 100644 src/client/builtins/tabs.tsx create mode 100644 src/client/builtins/viewers.tsx create mode 100644 tests/browser-globals.ts create mode 100644 tests/builtins.spec.ts create mode 100644 tests/orphaned-tab.spec.tsx diff --git a/src/client/OrphanedTab.tsx b/src/client/OrphanedTab.tsx index c62ce8b..2884a4d 100644 --- a/src/client/OrphanedTab.tsx +++ b/src/client/OrphanedTab.tsx @@ -20,7 +20,7 @@ export function OrphanedTab(props: TabComponentProps): ReactNode {
{t('pluginNotLoaded')} - {tab.type} + {tab.type}
) diff --git a/src/client/builtins.tsx b/src/client/builtins.tsx deleted file mode 100644 index 229bb68..0000000 --- a/src/client/builtins.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Built-in tab types and file viewers: the plugin registers its own - * built-in pages (explorer / git / terminal / subagent / editor / diff) - * and file previewers (image / pdf / docx / xlsx / pptx / markdown / code / - * binary-download) through the same {@link BetterSidebarService} external - * plugins use — eating its own dogfood. Code/markdown are registered - * viewers like any other (fsRead strategy, {@link TextEditor}); the - * `binary-download` viewer sniffs NUL bytes via `detect` for unknown - * binaries and serves doc/xls/ppt by extension. - */ -import { - IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16, -} from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from '../context-types.ts' -import { allLeaves, isAgentTabId, type SidebarState } from './state.ts' -import { mediaUrl } from './api.ts' -import { t } from './locales.ts' -import { openSidebarFile } from './intercept.tsx' -import { ExplorerView } from './ExplorerView.tsx' -import { EditorHost } from './EditorHost.tsx' -import { TextEditor } from './TextEditor.tsx' -import { TerminalView } from './TerminalView.tsx' -import { GitView } from './GitView.tsx' -import { DiffTab } from './DiffTab.tsx' -import { SubagentView } from './SubagentView.tsx' -import { DocxView, XlsxView } from './office-view.tsx' -import { PdfView } from './PdfView.tsx' -import { PptxView } from './PptxView.tsx' -import { BinaryDownload } from './binary-download.tsx' -import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx' -import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts' -import css from './sidebar.module.css' - -/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */ -const TERMINAL_LIMIT = 3 - -/** 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. */ -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, - single: true, - 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, - single: true, - 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, - single: true, - component: ({ ctx, scope, visible, onSubagentJump }) => ( - { onSubagentJump?.(address.childSessionId) }} - /> - ), - }, - { - id: 'terminal', - title: () => t('terminal'), - icon: (size: number) => , - order: 40, - available: (_ctx, _scope, 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 8 built-in file viewer descriptors — every preview surface is a registered viewer. */ -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: 'markdown', - exts: ['md', 'markdown'], - fetchStrategy: 'fsRead', - component: (props) => , - }, - { - id: 'code', - exts: [], - priority: -100, - fetchStrategy: 'fsRead', - component: (props) => , - }, - { - id: 'binary-download', - exts: ['doc', 'xls', 'ppt'], - priority: -50, - fetchStrategy: 'binary-download', - // NUL probe: a file whose head bytes contain a NUL is binary — claimed - // before the catch-all code viewer on the head re-match. - detect: (_path, head) => head.includes(0), - component: ({ scope, path }) => , - }, -] - -/** - * 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 - * (EditorHost 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/builtins/index.ts b/src/client/builtins/index.ts new file mode 100644 index 0000000..bad5352 --- /dev/null +++ b/src/client/builtins/index.ts @@ -0,0 +1,33 @@ +/** + * Built-in registration: the plugin registers its own tab pages and file + * previewers through the same {@link BetterSidebarService} external plugins + * use — eating its own dogfood. The descriptors live next to their feature + * modules (tabs.tsx / viewers.tsx); this module only aggregates them and + * owns the disposer lifecycle (cordis auto-invokes it on fiber disposal, + * HMR-safe). + */ +import type { Context } from '../../context-types.ts' +import type { BetterSidebarService } from '../service.ts' +import { builtinTabs } from './tabs.tsx' +import { builtinViewers } from './viewers.tsx' + +/** + * 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 + * (EditorHost 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/builtins/tabs.tsx b/src/client/builtins/tabs.tsx new file mode 100644 index 0000000..874ac4d --- /dev/null +++ b/src/client/builtins/tabs.tsx @@ -0,0 +1,127 @@ +/** + * The 6 built-in tab descriptors: the plugin registers its own pages + * (explorer / git / terminal / subagent / editor / diff) through the same + * {@link BetterSidebarService} external plugins use — eating its own + * dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and + * mints `terminal:` ids through `createTab`. + */ +import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, 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 { ExplorerView } from '../ExplorerView.tsx' +import { EditorHost } from '../EditorHost.tsx' +import { TerminalView } from '../TerminalView.tsx' +import { GitView } from '../GitView.tsx' +import { DiffTab } from '../DiffTab.tsx' +import { SubagentView } from '../SubagentView.tsx' +import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx' +import type { TabDescriptor } from '../service.ts' + +/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */ +export const TERMINAL_LIMIT = 3 + +/** 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): 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, + single: true, + 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, + single: true, + 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, + single: true, + component: ({ ctx, scope, visible, onSubagentJump }) => ( + { onSubagentJump?.(address.childSessionId) }} + /> + ), + }, + { + id: 'terminal', + title: () => t('terminal'), + icon: (size: number) => , + order: 40, + available: (_ctx, _scope, 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 + : + ), + }, + ] +} diff --git a/src/client/builtins/viewers.tsx b/src/client/builtins/viewers.tsx new file mode 100644 index 0000000..0d1cbcd --- /dev/null +++ b/src/client/builtins/viewers.tsx @@ -0,0 +1,86 @@ +/** + * The 8 built-in file viewer descriptors: every preview surface is a + * registered viewer (image / pdf / docx / xlsx / pptx / markdown / code / + * binary-download), exactly like external plugins register theirs. The + * `binary-download` viewer sniffs NUL bytes via `detect` for unknown + * binaries and serves doc/xls/ppt by extension; `code` is the catch-all + * (`exts: []`, lowest priority) that claims any file no other viewer did. + */ +import { DocxView, XlsxView } from '../office-view.tsx' +import { PdfView } from '../PdfView.tsx' +import { PptxView } from '../PptxView.tsx' +import { TextEditor } from '../TextEditor.tsx' +import { BinaryDownload } from '../binary-download.tsx' +import type { FileViewerDescriptor } from '../service.ts' +import css from '../sidebar.module.css' + +/** The 8 built-in file viewer descriptors. */ +export function builtinViewers(): readonly FileViewerDescriptor[] { + return [ + { + 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: 'markdown', + exts: ['md', 'markdown'], + fetchStrategy: 'fsRead', + component: (props) => , + }, + { + id: 'code', + exts: [], + priority: -100, + fetchStrategy: 'fsRead', + component: (props) => , + }, + { + id: 'binary-download', + exts: ['doc', 'xls', 'ppt'], + priority: -50, + fetchStrategy: 'binary-download', + // NUL probe: a file whose head bytes contain a NUL is binary — claimed + // before the catch-all code viewer on the head re-match. + detect: (_path, head) => head.includes(0), + component: ({ scope, path }) => , + }, + ] +} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index 2e0e30e..b53224e 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -1,14 +1,10 @@ /** * Icons the sidebar needs beyond the primitives set: a terminal glyph (the - * icon library has none) and the per-tab-type icon mapping shared by the - * tab strip, the + menu, and the empty-pane cards. + * icon library has none) and a diff glyph. Per-tab icons live on the tab + * descriptors (`descriptor.icon`), not in a type-keyed switch — the icon + * mapping was registry-ized with the tab types. */ -import type { ReactNode } from 'react' -import { - IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16, -} from '@deepseek-ai/dsh-client-ui-primitives' import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TabType } from './state.ts' /** * Terminal glyph in the app's outline style (1.5px stroke, currentColor): @@ -30,16 +26,3 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => ( ) - -/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */ -export function tabTypeIcon(type: TabType, size = 16): ReactNode { - switch (type) { - case 'explorer': return - case 'git': return - case 'terminal': return - 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 9589995..c2d63b1 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -13,7 +13,7 @@ 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 { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' import { registerTurnTailInterception } from './intercept.tsx' import { loadPrefs } from './prefs.ts' diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 3ed2a10..afd0f01 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -640,6 +640,15 @@ text-align: center; } +/* The unregistered tab type shown by the orphaned-tab placeholder. */ +.orphanedType { + display: block; + margin-top: 8px; + font-size: 12px; + opacity: 0.7; + overflow-wrap: anywhere; +} + /* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries): a centered notice plus a download link — the user always gets the file. */ .editorBinary { diff --git a/tests/browser-globals.ts b/tests/browser-globals.ts new file mode 100644 index 0000000..d636a90 --- /dev/null +++ b/tests/browser-globals.ts @@ -0,0 +1,59 @@ +/** + * Browser globals for specs that pull browser-bound modules. Import this + * FIRST in a spec file: unlike statements after imports (which vitest + * hoists above the module body), an import statement evaluates in order, + * so this runs before any later import's module graph (xterm's UMD wrapper + * checks `self` and its color defaults probe `document` at evaluation time; + * window/localStorage are only touched at runtime by the store). + */ +const g = globalThis as Record + +if (g.self === undefined) g.self = globalThis + +const elementStub = (): Record => ({ + style: {}, + classList: { add: () => {}, remove: () => {}, contains: () => false }, + setAttribute: () => {}, + getAttribute: () => null, + appendChild: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), + // xterm's color palette probe measures a canvas context at module load. + getContext: () => null, +}) + +if (g.document === undefined) { + g.document = { + createElement: () => elementStub(), + createDocumentFragment: () => elementStub(), + addEventListener: () => {}, + removeEventListener: () => {}, + defaultView: globalThis, + body: elementStub(), + // CodeMirror's view module probes the UA and injects styles at load. + documentElement: elementStub(), + head: elementStub(), + styleSheets: [], + } +} + +if (g.navigator === undefined) { + g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 } +} + +if (g.window === undefined) { + g.window = { + clearTimeout: () => {}, + setTimeout: (_fn: () => void) => 0, + innerWidth: 1024, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + } +} + +if (g.localStorage === undefined) { + g.localStorage = { + getItem: () => null, + setItem: () => {}, + } +} diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts new file mode 100644 index 0000000..87c335f --- /dev/null +++ b/tests/builtins.spec.ts @@ -0,0 +1,102 @@ +/** + * Built-in registration tests: the plugin registers 6 tabs and 8 file + * viewers through the same service external plugins use (dogfooding); + * the catch-all `code` viewer and the NUL-sniffing `binary-download` + * viewer pin the registry's matching behavior. + */ +import { describe, expect, it } from 'vitest' +// First import: browser globals before the xterm-carrying builtin graph loads. +import './browser-globals.ts' + +import type { Context } from '../src/context-types.ts' +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { registerBuiltins } from '../src/client/builtins/index.ts' + +function setup(): { service: ReturnType; dispose: () => void } { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + const dispose = registerBuiltins({} as Context, service) + return { service, dispose } +} + +describe('built-in tab registrations', () => { + it('registers the 6 built-in tabs', () => { + const { service } = setup() + expect(service.getTabs().map(t => t.id).sort()).toEqual( + ['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'], + ) + }) + + it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => { + const { service } = setup() + expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor']) + }) + + it('single-instance tabs use the single sugar', () => { + const { service } = setup() + for (const id of ['explorer', 'git', 'subagent']) { + expect(service.getTab(id)?.single).toBe(true) + } + }) +}) + +describe('built-in file viewer registrations', () => { + it('registers the 8 built-in file viewers', () => { + const { service } = setup() + expect(service.getFileViewers().map(v => v.id).sort()).toEqual( + ['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'], + ) + }) + + it('code is the catch-all at the lowest priority', () => { + const { service } = setup() + const code = service.getFileViewers().find(v => v.id === 'code') + expect(code?.exts).toEqual([]) + expect(code?.priority).toBe(-100) + expect(code?.fetchStrategy).toBe('fsRead') + expect(service.matchFileViewer('anything.zzz')?.id).toBe('code') + }) + + it('markdown claims md/markdown before the catch-all', () => { + const { service } = setup() + expect(service.matchFileViewer('readme.md')?.id).toBe('markdown') + expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown') + expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown') + }) + + it('binary-download claims legacy office by extension', () => { + const { service } = setup() + expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download') + expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download') + expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download') + }) + + it('binary-download NUL detect claims unknown-extension binaries over code', () => { + const { service } = setup() + // First match (no head) falls to the catch-all code viewer... + expect(service.matchFileViewer('blob.zzz')?.id).toBe('code') + // ...but the head re-match (NUL probe) routes it to binary-download. + expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download') + // A NUL-free blob stays with code. + expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code') + }) + + it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => { + const { service } = setup() + // A .docx is a zip (no NUL in its head): the docx viewer (priority 0) + // claims it before binary-download (-50) is consulted. + expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx') + }) +}) + +describe('built-in disposer', () => { + it('unregisters everything (HMR-safe)', () => { + const { service, dispose } = setup() + dispose() + expect(service.getTabs()).toHaveLength(0) + expect(service.getFileViewers()).toHaveLength(0) + // The disposer is idempotent. + dispose() + }) +}) diff --git a/tests/orphaned-tab.spec.tsx b/tests/orphaned-tab.spec.tsx new file mode 100644 index 0000000..64d092f --- /dev/null +++ b/tests/orphaned-tab.spec.tsx @@ -0,0 +1,87 @@ +/** + * Orphaned-tab tests: a persisted tab whose type is not registered must + * survive sanitize (title/path kept for recovery) and render the graceful + * placeholder instead of crashing — the degradation path of the open + * tab-type set (a contributing plugin not installed / not yet loaded). + */ +import { describe, expect, it } from 'vitest' +import { renderToString } from 'react-dom/server' +import { OrphanedTab } from '../src/client/OrphanedTab.tsx' +import { sanitizeState } from '../src/client/state.ts' + +/** Collect every tab type in a sanitized tree (leaf/split recursive). */ +function typesOf(node: unknown): string[] { + if (node === null || typeof node !== 'object') return [] + const record = node as { kind?: unknown; tabs?: unknown; children?: unknown } + if (record.kind === 'leaf' && Array.isArray(record.tabs)) { + return (record.tabs as Array<{ type: string }>).map(tab => tab.type) + } + if (Array.isArray(record.children)) return record.children.flatMap(typesOf) + return [] +} + +describe('sanitize keeps unregistered tab types', () => { + it('preserves a persisted tab of an unknown type with its title/path', () => { + const raw = { + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: null, + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + tabs: [ + { id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' }, + { id: 'tab:2', type: 'explorer', title: 'Explorer' }, + ], + active: 'tab:1', + }, + } + const clean = sanitizeState(raw) + expect(clean).toBeDefined() + expect(typesOf(clean!.splits)).toContain('my-plugin:db') + // The kept tab keeps its payload so a later registration can reuse it. + const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array> } + const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db') + expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' }) + }) + + it('an unknown type alone in a pane still sanitizes cleanly', () => { + const raw = { + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: null, + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }], + active: 'tab:1', + }, + } + const clean = sanitizeState(raw) + expect(clean).toBeDefined() + expect(typesOf(clean!.splits)).toEqual(['some-plugin:page']) + }) +}) + +describe('OrphanedTab placeholder', () => { + const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' } + + it('renders the unregistered type and keeps the title', () => { + const html = renderToString(( + + )) + expect(html).toContain('my-plugin:db') + expect(html).toContain('Database') + expect(html).not.toContain('undefined') + }) +}) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 50abd68..649cd05 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -21,7 +21,7 @@ if (g.localStorage === undefined) { } import { createBetterSidebarService } from '../src/client/service.ts' -import { createSidebarStore, allLeaves } from '../src/client/state.ts' +import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts' describe('BetterSidebar service', () => { it('registerTab adds to the registry and dispose removes it', () => { @@ -298,4 +298,26 @@ describe('service.openTab dedupe', () => { expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true) expect(seen).toHaveLength(1) }) + + it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'diff', + title: 'Diff', + dedupeKey: (tab) => tab.id, + component: () => null, + }) + store.setSession('s1') + const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false } + const paneId = store.getSnapshot().state!.activePane! + // The git view's placement path (split surgery) opens the diff tab... + store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed })) + // ...and a service open of the same change focuses the existing tab + // (the descriptor's dedupeKey is the same per-change id rule). + service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' }) + const state = store.getSnapshot().state! + const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff') + expect(tabs).toHaveLength(1) + }) }) From 39455ff18e158d5612275b5e29e11c3306a0c0b6 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 11:25:37 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs+fix:=20exports=20=E5=8A=A0=20./client/?= =?UTF-8?q?api=20=E5=88=AB=E5=90=8D=EF=BC=9Bcatch-all+detect=20=E7=BA=AF?= =?UTF-8?q?=E5=97=85=E6=8E=A2=E8=A7=84=E5=88=99=EF=BC=9B=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=81=8F=E5=B7=AE=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json exports 新增 './client/api' 别名指向 service.d.ts(设计文档 §7 的公共导入路径,api.ts 已被 fetch 助手占用故不换文件名) - matchFileViewer 补充 sniff-only 规则:exts: [] + detect 的 descriptor 在 无 head 时不得盲认领(AGENTS.md 的 magic-sniffer 示例原实现会吞掉 图片/PDF 等真实 viewer 的文件);有 head 时只认领 detect 命中的。 新增测试断言三种情形(无 head / magic 命中 / magic miss) - docs/plans/2026-08-11-service-registry-design.md 追加 §17 实施偏差记录: dedupeKey+single 语法糖、available 三参、单趟匹配+嗅探规则、EditorHost/ TextEditor 拆分、fs.read head 字段、导出别名、+ 菜单 disabled 行、 保留的 store 级操作、内置清单、测试 - AGENTS.md:available 三参、single 语法糖、viewerId、单趟匹配+head 语义、 8 viewer 内置清单、openTab seed.title 可选、参考实现指向 builtins/ 目录 - README 服务化章节:6 viewer → 8 viewer,标注全注册表驱动 --- AGENTS.md | 54 +++++++++++------- README.md | 2 +- .../2026-08-11-service-registry-design.md | 55 +++++++++++++++++++ package.json | 4 ++ src/client/service.ts | 16 +++++- tests/service.spec.ts | 20 +++++++ 6 files changed, 127 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e740899..9ca4918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ export function apply(ctx: Context): void { import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar' ``` -类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service` exports 子路径暴露。 +类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。 --- @@ -98,12 +98,17 @@ interface TabDescriptor { order?: number /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ hidden?: boolean - /** + 菜单禁用判定(如 terminal 配额满) */ - available?: (state: SidebarState) => boolean + /** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */ + available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean + /** + * 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有 + * 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。 + */ + single?: boolean /** * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 - * 返回 undefined 表示不去重(每次都新开)。 - * 内置策略:explorer/git/subagent 用 () => id(单实例);editor 用 tab => tab.path;diff 用 tab => tab.id。 + * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 + * 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。 */ dedupeKey?: (tab: SidebarTab) => string | undefined /** @@ -122,7 +127,7 @@ interface TabDescriptor { ```ts interface TabComponentProps { ctx: Context // client cordis context - store: SidebarStore // better-sidebar 的状态 store(可调 reduce/openTab 等) + store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等) scope: SessionScope // { sessionId, cwd? } tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?) visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等) @@ -146,7 +151,7 @@ ctx.effect(() => title: 'Notes', icon: , order: 50, - dedupeKey: () => 'my-plugin:notes', // 单实例 + single: true, // ≡ dedupeKey: () => 'my-plugin:notes' component: ({ scope }) => , }) ) @@ -230,6 +235,7 @@ interface FileViewerProps { scope: SessionScope path: string title: string + viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv') content?: string // fetchStrategy='fsRead' 时 truncated?: boolean // fetchStrategy='fsRead' 时 mediaUrl?: string // fetchStrategy='mediaUrl' 时 @@ -249,16 +255,17 @@ interface FileViewerProps { ### 4.4 匹配算法 -`matchFileViewer(path, head?)` 按以下顺序解析: +`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor: -1. **priority 降序**(稳定排序,相同 priority 按注册顺序) -2. **detect 嗅探**:若 `head` 字节可用,遍历 priority 降序的 viewer,第一个 `detect(path, head)` 返回 true 的命中(**覆盖 exts**) -3. **exts 匹配**:第一个 `exts` 含目标扩展名的 viewer 命中(`exts: []` 跳过此轮) -4. **catch-all**:第一个 `exts: []` 的 viewer 命中 -5. **fallback**:返回 `undefined`,EditorView 走 CodeMirror 兜底(按扩展名选语法高亮) +1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领) +2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中) -> **内置 viewer**(不可重复注册):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / binary-download(-50)。 -> code/markdown **没有**注册为 viewer,是 EditorView 的兜底逻辑(matchFileViewer 返回 undefined 时走 fsRead + CodeMirror/MarkdownText)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 +即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。 + +> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 + +> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。 +> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 ### 4.5 注册示例 @@ -324,10 +331,14 @@ interface BetterSidebarService { getFileViewers(): readonly FileViewerDescriptor[] /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined - /** 按 path 匹配 file viewer(priority desc → detect → exts) */ + /** 按 path 匹配 file viewer(priority 降序单趟:detect → exts) */ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined - /** 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重) */ - openTab(seed: { type: string; title: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void + /** + * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 + * title 可选:给出时优先于 descriptor.title(editor 显示文件名); + * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 + */ + openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void /** 关闭一个 tab */ closeTab(tabId: string): void /** 订阅注册表变化(register/dispose 时触发) */ @@ -439,9 +450,10 @@ function parseCsv(text: string): string[][] { /* ... */ } better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"): -- **`src/client/builtins.tsx`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 6 个内置 viewer(image/pdf/docx/xlsx/pptx/binary-download)的注册代码 +- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 -- **`tests/service.spec.ts`**:12 个测试覆盖 register/dispose/matchFileViewer/dedupe/createTab -- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档 +- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab 测试 +- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer) +- **`docs/plans/2026-08-11-service-registry-design.md`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/README.md b/README.md index af21455..653802b 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context - **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })` - **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })` -两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 6 个 viewer(image/pdf/docx/xlsx/pptx/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。 +两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 8 个 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。预览路由、+ 菜单、tab 图标全部由注册表驱动,无硬编码分发。 **消费插件最小骨架**: ```ts diff --git a/docs/plans/2026-08-11-service-registry-design.md b/docs/plans/2026-08-11-service-registry-design.md index 14e811a..58de763 100644 --- a/docs/plans/2026-08-11-service-registry-design.md +++ b/docs/plans/2026-08-11-service-registry-design.md @@ -359,3 +359,58 @@ export function apply(ctx: Context): void { 12. 重建 lib/,提交,推送,更新 profile web 详细实施计划由 writing-plans skill 生成。 + +--- + +## 17. 实施偏差记录(2026-08-11,feat/modular-registry) + +实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。 + +### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖 + +§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制: +- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达); +- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。 + +### 17.2 `available` 三参签名 + +§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。 + +### 17.3 匹配算法:单趟 per-descriptor,非三趟全局 + +§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。 + +### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer) + +§10 的 EditorView 职责一分为二: +- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`; +- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。 +- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。 + +### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰) + +§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。 + +### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名 + +§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。 + +### 17.7 + 菜单:disabled 行而非 filter + +§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。 + +### 17.8 保留的 store 级操作(非注册表职责) + +- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。 +- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。 +- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。 + +### 17.9 内置清单 + +- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。 +- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。 +- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。 + +### 17.10 测试 + +新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。 diff --git a/package.json b/package.json index c4e76db..f31927c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "types": "./lib/types/client/service.d.ts", "default": "./lib/client.js" }, + "./client/api": { + "types": "./lib/types/client/service.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/src/client/service.ts b/src/client/service.ts index bd755a4..24554c0 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -222,8 +222,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe for (const v of Array.from(viewers.values()).sort( (a, b) => (b.priority ?? 0) - (a.priority ?? 0), )) { - if (head !== undefined && v.detect !== undefined && v.detect(path, head)) return v - if (v.exts.length === 0 || v.exts.includes(ext)) return v + // Content sniff first (only when head bytes are available). + if (head !== undefined && v.detect !== undefined) { + if (v.detect(path, head)) return v + // A catch-all with detect is SNIFF-ONLY: it must not blind-claim + // paths it never sniffed (a magic-number viewer must not swallow + // every file before the real viewers get their turn). + if (v.exts.length === 0) continue + } else if (v.exts.length === 0) { + // Blind catch-all (no detect) claims anything; a sniff-only + // catch-all (detect defined, no head yet) yields this round. + if (v.detect === undefined) return v + continue + } + if (v.exts.includes(ext)) return v } return undefined } diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 649cd05..49999e1 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -144,6 +144,26 @@ describe('matchFileViewer', () => { expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext') }) + it('a catch-all with detect is sniff-only: it never blind-claims without head', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null }) + service.registerFileViewer({ + id: 'magic-sniffer', + exts: [], + priority: 100, + fetchStrategy: 'custom', + detect: (_path, head) => head[0] === 0x89, + component: () => null, + }) + // No head: the sniff-only catch-all yields — the real png viewer wins. + expect(service.matchFileViewer('photo.png')?.id).toBe('img') + // Head with the magic: the sniffer claims it (detect at priority 100). + expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer') + // Head without the magic: the sniffer yields again, img claims it. + expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img') + }) + it('returns undefined when no viewer matches and no catch-all', () => { const store = createSidebarStore() const service = createBetterSidebarService(store)