Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375// src/layout/SidebarViewContainer.tsximport { 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<ReactNode>(() => { 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 // <button class="view-action"> to the view's containerEl. The host // moves those buttons here so they appear in the chrome row's actions // slot alongside the (modern, React-driven) getHeaderActions() output. // // We use direct DOM reparenting rather than React portals so that the // buttons keep their identity across re-renders (their click handlers // and registered DOM events are bound to the element instance). // // Late additions: plugins may call addAction() asynchronously inside // onOpen() (e.g. after an awaited fetch). To catch buttons added after // initial activation, we MutationObserver the view's containerEl and // reconcile when new .view-action children appear. Origins (the // button's prior parent) are kept in a Map so cleanup restores each // button to its true home, not just the first one we saw. const addActionSlotRef = useRef<HTMLDivElement>(null) useEffect(() => { const slot = addActionSlotRef.current if (!slot || !activeView) return
const containerEl = (activeView as { containerEl?: HTMLElement }).containerEl const origins = new Map<HTMLElement, Node>()
/** Move a single action button into the slot, remembering its origin. */ const adopt = (el: HTMLElement) => { if (origins.has(el)) return const parent = el.parentNode if (!parent || parent === slot) return origins.set(el, parent) slot.appendChild(el) }
/** Snapshot whatever is on _actionElements right now. */ const reconcile = () => { const actions = (activeView as { _actionElements?: HTMLElement[] })._actionElements if (!actions) return for (const el of actions) adopt(el) }
reconcile()
// Observe containerEl for late .view-action additions. Listening on // childList only — we don't care about attribute changes on existing // buttons. The observer disconnects in cleanup. let observer: MutationObserver | null = null if (containerEl) { observer = new MutationObserver(() => { reconcile() }) observer.observe(containerEl, { childList: true, subtree: false }) }
return () => { observer?.disconnect() // Return each adopted button to its original parent. Skip // detached nodes (parentNode is null when the plugin destroyed // the original parent before our cleanup fires). for (const [el, parent] of origins) { if (el.parentNode === slot && (parent as Node).isConnected) { parent.appendChild(el) } } } }, [activeView])
if (leaves.length === 0) { if (loading || allLeaves.some(leaf => leaf.view === null)) { const tabCount = allLeaves.length || loadPersistedSidebarLeaves().filter(leaf => leaf.side === side && (typesEnabled || leaf.viewType !== TYPES_BROWSER_VIEW_TYPE)).length return <SidebarLoading side={side} tabCount={tabCount} /> } return ( <div className="sidebar-view-container" data-side={side}> <div className="sidebar-view-placeholder">No views available</div> </div> ) }
const headerTitle = activeLeaf ? (activeLeaf.getDisplayText() || activeLeaf.getViewType()) : ''
// Has-actions flag: render the actions slot if EITHER getHeaderActions // returned something OR the view's addAction() list is non-empty. // Both can coexist (React content + DOM-button list) — that's expected // for plugins that mix Reams-native and Obsidian-compat patterns. const hasAddActionButtons = activeView && ((activeView as { _actionElements?: HTMLElement[] })._actionElements?.length ?? 0) > 0 const showActionsSlot = headerActions != null || hasAddActionButtons
return ( <div className="sidebar-view-container" data-side={side}> <div className="sidebar-view-tabs" role="tablist" aria-orientation="vertical"> {leaves.map((leaf, i) => ( <button key={leaf.id} className="sidebar-view-tab" data-view-type={leaf.getViewType()} role="tab" aria-selected={i === activeLeafIndex} aria-label={leaf.getDisplayText() || leaf.getViewType()} title={leaf.getDisplayText() || leaf.getViewType()} onClick={() => handleTabClick(leaf)} > <TabIcon iconName={leaf.view?.getIcon() ?? 'document'} fallbackText={leaf.getDisplayText() || leaf.getViewType()} /> </button> ))} </div> <div className="sidebar-view-panel" role="tabpanel"> {/* 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. */} <div className="sidebar-view-header"> <span className="sidebar-view-header__title">{headerTitle}</span> {showActionsSlot && ( <div className="sidebar-view-header__actions"> {headerActions} {/* DOM portal slot for ItemView.addAction() buttons; the reparenting effect above moves them in/out by view. */} <div ref={addActionSlotRef} className="sidebar-view-header__addaction-slot" /> </div> )} </div> <div className="sidebar-view-body"> {leaves.map((leaf, i) => ( <LeafMount key={leaf.id} leaf={leaf} visible={i === activeLeafIndex} /> ))} </div> </div> </div> )}
/** * 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<HTMLDivElement>(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 ( <div ref={containerRef} className="sidebar-view-leaf" data-visible={visible} > {reactComponent} </div> )}