diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index e2fbe0a..acdbb9f 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -28,7 +28,7 @@ * drawer floats). Widening does not migrate back: the tabs keep living in * the right tree. */ -import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -51,17 +51,14 @@ import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' import { computeTitleBarStrip } from './titlebar-strip.ts' -import type { NewTabOption } from './TabBar.tsx' +import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { FreeWindow } from './FreeWindow.tsx' import { relativeTo } from './paths.ts' -import { OrphanedTab } from './OrphanedTab.tsx' -import { RenderBoundary } from './RenderBoundary.tsx' -import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts' import { detectNewDirectSubagent } from './subagent-detect.ts' import { detectNewJob } from './subagent-jobs.ts' import { t } from './locales.ts' -import { api, type SessionScope } from './api.ts' +import { api } from './api.ts' import css from './sidebar.module.css' /** How many consecutive reconnect failures stop the agent-terminals push loop @@ -119,65 +116,6 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem return tag } -/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus - * the runtime objects/callbacks the cell renders with. The memo comparator - * is the pure `tabContentCompare`; anything in the key decides a re-render - * must propagate, anything outside it must be a stable object (ctx/store) - * or covered by a compared field (paneId covers onOpenDiff's captured - * pane; sessionId/cwd cover onReferenceFile). */ -interface TabContentProps extends TabContentMemoKey { - onToggleDir: (path: string) => void - onReferenceFile: (path: string, isDir: boolean) => void - ctx: Context - store: SidebarStore - /** Fired before a topology node jumps to its child session (see Sidebar). */ - onSubagentJump: (childSessionId: string) => void - /** Open a diff tab from the git panel (placement handled by the store). */ - onOpenDiff: (tab: SidebarTab) => void -} - -/** Render the content of one tab (dispatched by type). */ -const TabContent = memo(function TabContent(props: TabContentProps) { - const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props - const scope = { sessionId, cwd } - const descriptor = ctx.get('betterSidebar')?.getTab(tab.type) - if (descriptor === undefined) { - return - } - // For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView) - // must receive the ORIGINAL tab id so it connects to the home session's PTY. - // The virtual tab's own id is a unique display key (prefixed); effectiveTabId - // restores the real id at the component boundary. - const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab - return createElement( - RenderBoundary, - { className: css.tabBoundaryError }, - createElement(descriptor.component, { - ctx, store, scope, tab: componentTab, visible, expanded, revealed, - onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, - }), - ) -}, tabContentCompare) - -/** 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. - * Tabs the user disabled in the side card settings are filtered out - * entirely — re-enabling them is the settings page's job. */ -function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] { - const service = ctx.get('betterSidebar') - if (service === undefined) return [] - return service.getTabs() - .filter(d => !d.hidden && service.isTabEnabled(d.id)) - .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) - .map(d => ({ - id: d.id, - label: typeof d.title === 'function' ? d.title() : d.title, - disabled: !(d.available?.(ctx, scope, state) ?? true), - icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, - })) -} - export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { ctx, store } = props diff --git a/src/client/sidebar/TabContent.tsx b/src/client/sidebar/TabContent.tsx new file mode 100644 index 0000000..4755ec4 --- /dev/null +++ b/src/client/sidebar/TabContent.tsx @@ -0,0 +1,74 @@ +/** + * The tab-content cell + the + menu builder, extracted from Sidebar.tsx + * (behavior identical): one memoized cell dispatches a tab to its registry + * descriptor's component, and buildNewTabOptions derives the + menu rows + * from the same registry. + */ +import { createElement, memo } from 'react' +import type { Context } from '../../context-types.ts' +import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts' +import type { SessionScope } from '../api.ts' +import { OrphanedTab } from '../OrphanedTab.tsx' +import { RenderBoundary } from '../RenderBoundary.tsx' +import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts' +import type { NewTabOption } from '../TabBar.tsx' +import css from '../sidebar.module.css' + +/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus + * the runtime objects/callbacks the cell renders with. The memo comparator + * is the pure `tabContentCompare`; anything in the key decides a re-render + * must propagate, anything outside it must be a stable object (ctx/store) + * or covered by a compared field (paneId covers onOpenDiff's captured + * pane; sessionId/cwd cover onReferenceFile). */ +interface TabContentProps extends TabContentMemoKey { + onToggleDir: (path: string) => void + onReferenceFile: (path: string, isDir: boolean) => void + ctx: Context + store: SidebarStore + /** Fired before a topology node jumps to its child session (see Sidebar). */ + onSubagentJump: (childSessionId: string) => void + /** Open a diff tab from the git panel (placement handled by the store). */ + onOpenDiff: (tab: SidebarTab) => void +} + +/** Render the content of one tab (dispatched by type). */ +export const TabContent = memo(function TabContent(props: TabContentProps) { + const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props + const scope = { sessionId, cwd } + const descriptor = ctx.get('betterSidebar')?.getTab(tab.type) + if (descriptor === undefined) { + return + } + // For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView) + // must receive the ORIGINAL tab id so it connects to the home session's PTY. + // The virtual tab's own id is a unique display key (prefixed); effectiveTabId + // restores the real id at the component boundary. + const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab + return createElement( + RenderBoundary, + { className: css.tabBoundaryError }, + createElement(descriptor.component, { + ctx, store, scope, tab: componentTab, visible, expanded, revealed, + onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, + }), + ) +}, tabContentCompare) + +/** 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. + * Tabs the user disabled in the side card settings are filtered out + * entirely — re-enabling them is the settings page's job. */ +export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] { + const service = ctx.get('betterSidebar') + if (service === undefined) return [] + return service.getTabs() + .filter(d => !d.hidden && service.isTabEnabled(d.id)) + .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) + .map(d => ({ + id: d.id, + label: typeof d.title === 'function' ? d.title() : d.title, + disabled: !(d.available?.(ctx, scope, state) ?? true), + icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, + })) +}