// src/layout/SidebarViewContainer.tsx import { useEffect, useRef, useState, useCallback, useMemo, type ReactNode } from 'react' import type { WorkspaceLeaf, PanelSide } from '../plugin/workspace' import type { App } from '../plugin/app' import { isReactItemView, type ReactItemView } from '../plugin/views/item-view' import { useWorkspaceViews } from './hooks/useWorkspaceViews' import { usePanelActiveLeaf } from './hooks/usePanelActiveLeaf' import { TabIcon } from './TabIcon' import { SidebarLoading } from './SidebarLoading' import { TYPES_BROWSER_VIEW_TYPE } from '../plugins/types-browser/types-browser-view' import { useTypesRecordsEnabled } from '../types-records/feature-setting' import { loadPersistedActiveViewTypes, loadPersistedSidebarLeaves, savePersistedActiveViewType, } from './sidebar-leaf-persistence' import './SidebarViewContainer.css' interface SidebarViewContainerProps { app: App side: PanelSide loading?: boolean } /** * Container for plugin-registered panel views. * Renders vertical icon tabs (activity bar) on the inner edge of the panel. * Works for both left sidebar and right panel via the `side` prop. * * The Workspace is the single source of truth for which tab is active * (via workspace.activeLeftLeaf / activeRightLeaf). This component reads * from that state and writes to it via workspace.setActivePanelLeaf(). * localStorage persistence is a side-effect of workspace state changes, * used only for bootstrap on first mount. * * Supports two rendering modes: * - ReactItemView: renders getReactComponent() inline (preserves React context) * - DOM-based ItemView: mounts view.containerEl via ref */ export function SidebarViewContainer({ app, side, loading = false }: SidebarViewContainerProps) { const workspace = app.workspace const { leftLeaves, rightLeaves } = useWorkspaceViews(app) const allLeaves = side === 'left' ? leftLeaves : rightLeaves const typesEnabled = useTypesRecordsEnabled() // Memoize the filtered leaves list to stabilize array identity. // Without this, filter() creates a new array on every render, causing // downstream useEffects (bootstrap/fallback) to re-run unnecessarily. const leaves = useMemo( () => { const order = (leaf: WorkspaceLeaf) => isReactItemView(leaf.view) ? leaf.view.sidebarOrder ?? 0 : 0 return allLeaves.filter(l => l.view != null && (typesEnabled || l.getViewType() !== TYPES_BROWSER_VIEW_TYPE)) .sort((a, b) => order(a) - order(b)) }, [allLeaves, typesEnabled] ) // Read active panel leaf from workspace (single source of truth) const activePanelLeaf = usePanelActiveLeaf(app, side) // Counter-backed subscription for `view-header-actions-change` events. // The active leaf reference is unchanged across a // `requestHeaderActionsUpdate()` call, so piggybacking on // `panel-leaf-change` would silently fail (React bails on Object.is). // A monotonically-increasing counter forces re-render and the // `headerActions` memo to recompute. const [headerActionsVersion, setHeaderActionsVersion] = useState(0) useEffect(() => { const ref = app.reamsEvents.on('view-header-actions-change', (leaf) => { // Only react if the firing leaf is the one we're currently showing. // Avoids tearing two panels apart when an unrelated leaf updates. const current = side === 'left' ? workspace.activeLeftLeaf : workspace.activeRightLeaf if (leaf === current) setHeaderActionsVersion((v) => v + 1) }) return () => app.reamsEvents.offref(ref) }, [app, workspace, side]) // Bootstrap + fallback sync: manage workspace active panel leaf state. // // On first mount: restore persisted view type from localStorage. // On subsequent renders: if the active leaf is gone (e.g. view unregistered), // fall back to the first available leaf. // // Both cases write to workspace via setActivePanelLeaf (single source of truth). const bootstrappedRef = useRef(false) useEffect(() => { if (leaves.length === 0) return if (!bootstrappedRef.current) { // First run: bootstrap from localStorage bootstrappedRef.current = true const activeTypes = loadPersistedActiveViewTypes() const persisted = activeTypes[side] if (persisted) { const matchingLeaf = leaves.find(l => l.getViewType() === persisted) if (matchingLeaf) { workspace.setActivePanelLeaf(matchingLeaf) return } } // No persisted match — default to first leaf workspace.setActivePanelLeaf(leaves[0]) } else { // Post-bootstrap: if the current active leaf is no longer in the list // (e.g. plugin was disabled), fall back to the first available leaf. const currentLeaf = side === 'left' ? workspace.activeLeftLeaf : workspace.activeRightLeaf if (!currentLeaf || leaves.indexOf(currentLeaf) === -1) { workspace.setActivePanelLeaf(leaves[0]) } } }, [leaves, side, workspace]) // Persist to localStorage whenever the workspace active panel leaf changes useEffect(() => { const viewType = activePanelLeaf?.getViewType() ?? null if (viewType !== null) { savePersistedActiveViewType(side, viewType) } }, [activePanelLeaf, side]) // Resolve which leaf index is active based on workspace state. // Falls back to first leaf if the active leaf is not in the current list. let activeLeafIndex = -1 if (leaves.length > 0) { if (activePanelLeaf) { const idx = leaves.indexOf(activePanelLeaf) activeLeafIndex = idx !== -1 ? idx : 0 } else { activeLeafIndex = 0 } } const handleTabClick = useCallback((leaf: WorkspaceLeaf) => { // Write to the single source of truth — workspace state. // This fires 'panel-leaf-change' which triggers re-render via usePanelActiveLeaf. // Does NOT set global activeLeaf — clicking a sidebar tab shouldn't steal // keyboard focus from the editor. workspace.setActivePanelLeaf(leaf) }, [workspace]) // Hooks below this point must run unconditionally on every render // (Rules of Hooks). We compute activeLeaf/activeView from the // already-resolved activeLeafIndex; if there are no leaves they are // simply null and the memo/effect bodies short-circuit. const activeLeaf = activeLeafIndex >= 0 ? leaves[activeLeafIndex] : null const activeView = activeLeaf?.view ?? null // Header actions come from the active leaf's ReactItemView, if any. // // The result is memoized on activeView identity for two reasons: // (1) Render isolation: getHeaderActions() is plugin-controlled code. // We catch synchronous throws and reject Promise-shaped returns so a // buggy plugin can't unmount the sidebar tree or trigger a React // "Objects are not valid as a React child" on the next render. // (2) Referential stability: the FileTreeView impl (and any future plugin // impl) creates a fresh React element + arrow function on every call. // SidebarViewContainer re-renders on every workspace event, so without // this memo we'd defeat React.memo on any future memoized action // button and re-invoke the plugin's work on every event. // Re-runs only when the active view identity changes (e.g. user switches // panels) — which is exactly when a plugin would legitimately want to // recompute its actions slot. const headerActions = useMemo(() => { if (!activeView || !isReactItemView(activeView)) return null const fn = activeView.getHeaderActions if (typeof fn !== 'function') return null try { const result = fn.call(activeView) // Defend against a misuse where a plugin returns a thenable: React // can't render it, and the error message ("Objects are not valid as // a React child") is more confusing than a silent null. if (result && typeof (result as { then?: unknown }).then === 'function') { console.error( `getHeaderActions() returned a thenable for view ${activeView.getViewType?.() ?? '?'}.` + ' Header actions must be synchronous.', ) return null } return result as ReactNode } catch (err) { console.error( `getHeaderActions() threw for view ${activeView.getViewType?.() ?? '?'}:`, err, ) return null } // headerActionsVersion intentionally included so that // `view.requestHeaderActionsUpdate()` (which fires // 'view-header-actions-change' and bumps this counter) forces a // recompute even though `activeView` identity is unchanged. }, [activeView, headerActionsVersion]) // Portal slot for ItemView.addAction() buttons. Plugins targeting the // Obsidian-compat surface call `view.addAction()` which appends a // ))}
{/* Unified chrome row. The host renders the canonical panel header (title + optional getHeaderActions slot) at --chrome-row-height so its bottom border lines up with the central TabBar. Plugins should NOT render their own header inside the leaf body — the host has no way to enforce this physically (nothing stops a plugin from drawing a tall element at the top of its leaf), but the contract is documented on ReactItemView. */}
{headerTitle} {showActionsSlot && (
{headerActions} {/* DOM portal slot for ItemView.addAction() buttons; the reparenting effect above moves them in/out by view. */}
)}
{leaves.map((leaf, i) => ( ))}
) } /** * Mounts a WorkspaceLeaf's view into the React tree. * - ReactItemView: renders getReactComponent() inline (preserves React context) * - DOM-based ItemView: mounts view.containerEl via ref */ function LeafMount({ leaf, visible }: { leaf: WorkspaceLeaf; visible: boolean }) { const containerRef = useRef(null) const view = leaf.view const isReact = view != null && isReactItemView(view) const reactComponent = useMemo( () => (isReact ? (view as unknown as ReactItemView).getReactComponent() : null), [view, isReact] ) useEffect(() => { const el = containerRef.current if (!el || !view || reactComponent) return el.appendChild(view.containerEl) return () => { if (el.contains(view.containerEl)) { el.removeChild(view.containerEl) } } }, [view, reactComponent]) return (
{reactComponent}
) }