fix(service): 注册表核心修正 — seed.title 优先、single 语法糖、available 三参、单趟匹配、dedupe 委托
- 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 用例
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state, ctx)}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { TERMINAL_LIMIT, allLeaves, isAgentTabId, type SidebarState } from './state.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from './state.ts'
|
||||
import { downloadUrl, mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
@@ -28,6 +28,9 @@ 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)
|
||||
@@ -54,7 +57,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
dedupeKey: () => 'explorer',
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
@@ -71,7 +74,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
dedupeKey: () => 'git',
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
@@ -85,7 +88,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
dedupeKey: () => 'subagent',
|
||||
single: true,
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
@@ -100,7 +103,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
title: () => t('terminal'),
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
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
|
||||
|
||||
+55
-48
@@ -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:<n>` 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). */
|
||||
|
||||
+21
-20
@@ -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
|
||||
}
|
||||
|
||||
+121
-9
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+19
-19
@@ -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<SplitNode, { kind: 'split' }>
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user