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)