import { useEffect, useCallback, useRef, useState, useMemo, useSyncExternalStore } from 'react' import type { AutomergeUrl } from '../storage/document-runtime-contract' import type { DocumentManager } from '../services/document-manager' import type { RecentVisits } from '../services/recent-visits' import type { App } from '../plugin/app' import { useLayout } from './LayoutContext' import { useSearch } from '../services/SearchContext' import { Header } from './Header' import { TabBar } from './TabBar' import { Notice } from 'obsidian' import { Sidebar } from './Sidebar' import { SidebarViewContainer } from './SidebarViewContainer' import { ResizeHandle } from './ResizeHandle' import { Ribbon } from './Ribbon' import { StatusBar } from './StatusBar' import { FuzzySearchOverlay, useSearchOverlay } from './fuzzy-search' import type { SearchCreatePreview } from './fuzzy-search/FuzzySearchOverlay' import { CommandPalette, useCommandPalette } from './command-palette' import { useAutoCloseSidebars } from './hooks/useAutoCloseSidebars' import { useSwipeGesture, type VerticalSwipeProgress } from './hooks/useSwipeGesture' import type { HorizontalSwipeAction } from './hooks/gesture-detection' import { setGestureReturnMode, consumeGestureReturnMode, clearGestureReturnMode } from './hooks/gesture-return-mode' import { GestureIndicator } from './GestureIndicator' import { loadGestureSettings } from './gesture-settings' import { executeGestureCommand } from './gesture-command-utils' import { SettingsModal, useSettings } from './settings' import { CreateItemModal } from './file-tree' import { newBaseYaml } from '../bases/default-base' import { executeSearchCreate, resolveSearchCreatePreview } from './search-create' // Plugin imports import type { WorkspaceLeaf } from '../plugin/workspace' import type { TFile } from '../plugin/files' import { useWorkspaceTabs } from './hooks/useWorkspaceTabs' import { FileTreePlugin } from '../plugins/file-tree' import { QuickAccessPlugin } from '../plugins/quick-access' import { TypesBrowserPlugin } from '../plugins/types-browser' import { TYPES_BROWSER_VIEW_TYPE } from '../plugins/types-browser/types-browser-view' import { isTypesRecordsEnabled } from '../types-records/feature-setting' import { QuickSearchPlugin } from '../plugins/quick-search' import { WikilinkPlugin } from '../plugins/wikilink' import { DailyNotesPlugin } from '../plugins/daily-notes' import { CreateFilePlugin } from '../plugins/create-file' import { WordCountPlugin } from '../plugins/word-count' import { DocumentInfoPlugin } from '../plugins/document-info' import { OfmtShimPlugin } from '../plugins/ofmt-shim' import { TemplatePlugin } from '../plugins/templates' import { WordHighlighterPlugin } from '../plugins/word-highlighter' import { HighlightPlugin } from '../plugins/highlight' import { BuiltinFormattingPlugin } from '../plugins/builtin-formatting' import { LayoutCommandsPlugin } from '../plugins/layout-commands' import { ImageViewerPlugin, IMAGE_VIEWER_MANIFEST } from '../plugins/image-viewer' // Plugin loader imports import { PluginLoader } from '../plugin/loader/plugin-loader' import { PluginRegistry } from '../plugin/loader/plugin-registry' import { PluginSettingsStore } from '../plugin/loader/plugin-settings-store' import { EnabledStateStore } from '../plugin/loader/enabled-state-store' import { ExternalPluginStore } from '../plugin/loader/external-plugin-store' import { installPluginApiGlobal } from '../plugin/loader/plugin-api-global' import { loadPersistedSidebarLeaves, savePersistedSidebarLeaves } from './sidebar-leaf-persistence' import { saveWorkspaceTabs } from './layout-persistence' import { persistWorkspaceTabsOnLifecycle } from './workspace-tab-lifecycle-persistence' import type { EditorMode } from './layout-state' import type { ReamsEdition } from '../editions/types' import type { EditionAccountUiHook, EditionFeedbackModal, EditionPublishContribution, EditionSyncSettingsSection } from '../editions/contributions' import type { SaveStatus } from '../sync/sync-contract' import type { BootStage } from '../app/boot-stages' import { MOBILE_BREAKPOINT, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_RIGHT_PANEL_WIDTH, MAX_RIGHT_PANEL_WIDTH, computeToolbarLeftOffset, computeToolbarRightOffset, } from './layout-state' import './AppShell.css' import { useReactiveTemplatePlugin, isValidTemplatePlugin } from './useReactiveTemplatePlugin' import { findHeadingBySlug } from '../services/metadata' import { registerAppShellInvariantProvider } from '../testing/invariants/reams-invariant-diagnostics' import { MainAreaViewContainer } from './MainAreaViewContainer' import { MainAreaContext, type MainAreaContextValue } from './MainAreaContext' import { createPromptlessNote } from './promptless-note-creation' export function useVisualViewportCssVars(): void { useEffect(() => { let frame = 0 const update = () => { frame = 0 const viewport = window.visualViewport const height = viewport?.height ?? window.innerHeight const offsetTop = viewport?.offsetTop ?? 0 const style = document.documentElement.style style.setProperty('--reams-visual-viewport-height', `${height}px`) style.setProperty('--reams-visual-viewport-offset-top', `${offsetTop}px`) } const scheduleUpdate = () => { if (frame !== 0) return frame = window.requestAnimationFrame(update) } // A home-screen PWA can resume with new geometry without a viewport event. // Discard any frame queued before suspension, read now, then read again // next frame as the browser settles its viewport after unlock. const refreshOnResume = () => { if (document.visibilityState === 'hidden') return if (frame !== 0) window.cancelAnimationFrame(frame) update() scheduleUpdate() } update() window.addEventListener('resize', scheduleUpdate) window.addEventListener('pageshow', refreshOnResume) document.addEventListener('visibilitychange', refreshOnResume) window.visualViewport?.addEventListener('resize', scheduleUpdate) window.visualViewport?.addEventListener('scroll', scheduleUpdate) return () => { if (frame !== 0) window.cancelAnimationFrame(frame) window.removeEventListener('resize', scheduleUpdate) window.removeEventListener('pageshow', refreshOnResume) document.removeEventListener('visibilitychange', refreshOnResume) window.visualViewport?.removeEventListener('resize', scheduleUpdate) window.visualViewport?.removeEventListener('scroll', scheduleUpdate) document.documentElement.style.removeProperty('--reams-visual-viewport-height') document.documentElement.style.removeProperty('--reams-visual-viewport-offset-top') } }, []) } /** Wikilink handler props passed to the editor. * * `getSuggestions` and `onSelectSuggestion` were removed in #234 — wikilink * autocomplete now flows through the EditorSuggest framework * (`src/plugin/editor-suggest.ts` + `src/editor/extensions/editor-suggest-dispatcher.ts`). * Only navigation + link resolution remain, used by the wikilink chip widget. */ export interface WikilinkHandlers { onNavigateWikilink: (target: string) => void resolveLink: (target: string) => boolean } interface AppShellProps { /** DocumentManager instance for the file tree. */ documentManager: DocumentManager /** App instance for plugin system access. */ app: App /** Current editor mode for the header toggle. */ editorMode?: EditorMode /** Callback when user clicks the header mode toggle. */ onToggleMode?: () => void /** Save status for the status bar. */ saveStatus?: SaveStatus /** Whether document is open in another tab. */ isConcurrentTabOpen?: boolean /** @internal Save-status broadcaster used by MarkdownLeafBinding (#406). */ onSaveStatusChange?: (s: SaveStatus) => void /** @internal Concurrent-tab broadcaster used by MarkdownLeafBinding (#406). */ onConcurrentTabChange?: (open: boolean) => void edition?: ReamsEdition accountUi?: EditionAccountUiHook syncSettingsSection?: EditionSyncSettingsSection /** Called once the shell, plugins and first view are usable. */ onReady?: () => void onStage?: (stage: BootStage) => void enabledStateStore?: EnabledStateStore recentVisits?: RecentVisits feedbackModal?: EditionFeedbackModal publishContribution?: EditionPublishContribution } /** * Built-in plugin IDs for first-run defaults. * * Order matters: templates load before note-creation plugins. Among plugins * with sidebar onLayoutReady callbacks, Quick Access precedes File Tree so * it becomes workspace.leftLeaves[0] (the top sidebar tab). */ const BUILTIN_PLUGIN_IDS = [ // Template defaults must be ready before any note-creation entry point. 'reams-templates', 'reams-quick-access', 'reams-types-browser', 'reams-file-tree', 'reams-quick-search', 'reams-wikilink', 'reams-daily-notes', 'reams-create-file', 'reams-word-count', 'reams-document-info', 'obsidian-front-matter-title-plugin', 'reams-word-highlighter', 'reams-highlight', 'reams-builtin-formatting', 'reams-layout-commands', 'reams-image-viewer', ] function loadAvailableSidebarLeaves() { return loadPersistedSidebarLeaves().filter(entry => entry.viewType !== TYPES_BROWSER_VIEW_TYPE || isTypesRecordsEnabled() ) } /** * Main app shell with layout: * - Ribbon (vertical icon strip) on the far left * - Collapsible left sidebar * - Main area with tab bar and editor * - Collapsible right panel * - Status bar at the bottom * * Resize handles between panels allow width adjustment. * Includes fuzzy search overlay triggered by Cmd/Ctrl+K (via QuickSearchPlugin). * Includes command palette triggered by Cmd/Ctrl+P. */ export function AppShell({ documentManager, app, editorMode, onToggleMode, saveStatus, isConcurrentTabOpen, onSaveStatusChange, onConcurrentTabChange, edition = 'classic', accountUi: useEditionAccountUi = () => ({ onAccountClick: undefined, accountCard: null }), syncSettingsSection, onReady, onStage, enabledStateStore, recentVisits, feedbackModal: FeedbackModalComponent, publishContribution, }: AppShellProps) { useVisualViewportCssVars() const { state, setSidebarWidth, setSidebarMode, setRightPanelWidth, setRightPanelCollapsed, toggleSidebar, setEditorMode, } = useLayout() const { searchService, metadataCache } = useSearch() const searchOverlay = useSearchOverlay(searchService) const [linkSearchSelect, setLinkSearchSelect] = useState<((file: TFile) => void) | null>(null) const openLinkSelectionSearch = useCallback((onSelect: (file: TFile) => void, initialQuery: string) => { setLinkSearchSelect(() => onSelect) searchOverlay.openWithQuery(initialQuery) }, [searchOverlay.openWithQuery]) const toggleSearch = useCallback(() => { setLinkSearchSelect(null) if (searchOverlay.isOpen) searchOverlay.close() else searchOverlay.open() }, [searchOverlay.close, searchOverlay.isOpen, searchOverlay.open]) // Read tabs from workspace (source of truth), not LayoutContext const { activeLeaf, leaves } = useWorkspaceTabs(app.workspace) const [corePluginsReady, setCorePluginsReady] = useState(false) const [pluginsFullyReady, setPluginsFullyReady] = useState(false) const [readyView, setReadyView] = useState<{ leaf: WorkspaceLeaf; view: WorkspaceLeaf['view']; path: string | null; url: string | null } | null>(null) const markInitialViewReady = useCallback((leaf: WorkspaceLeaf) => { if (app.workspace.activeLeaf === leaf) setReadyView({ leaf, view: leaf.view, path: leaf.file?.path ?? null, url: leaf.file ? app.vault.getUrlForFile(leaf.file) ?? null : null, }) }, [app.workspace, app.vault]) const lastActiveLeafRef = useRef(app.workspace.activeLeaf) useEffect(() => { const ref = app.workspace.on('active-leaf-change', () => { const next = app.workspace.activeLeaf if (next === lastActiveLeafRef.current) return lastActiveLeafRef.current = next setReadyView(null) }) return () => app.workspace.offref(ref) }, [app.workspace]) useEffect(() => { const viewReady = activeLeaf ? readyView?.leaf === activeLeaf && readyView.view === activeLeaf.view && readyView.path === (activeLeaf.file?.path ?? null) && readyView.url === (activeLeaf.file ? app.vault.getUrlForFile(activeLeaf.file) ?? null : null) : leaves.length === 0 if (!corePluginsReady) return if (viewReady) { if (new URLSearchParams(location.search).has('benchBreakdown')) performance.mark('reams:boot:view-ready-callback') onReady?.() app.workspace.resumeDeferredViews() } else onStage?.('opening-note') }, [corePluginsReady, readyView, activeLeaf, leaves.length, onReady, onStage, app.vault]) useEffect(() => { if (!pluginsFullyReady) return const timer = setTimeout(() => app.vault.startInitialStats?.(), 100) return () => clearTimeout(timer) }, [app.vault, pluginsFullyReady]) useEffect(() => { if (!recentVisits) return const record = () => { const file = app.workspace.getActiveFile() const url = file && app.vault.getUrlForFile(file) if (url) recentVisits.record(url) } record() const ref = app.workspace.on('active-leaf-change', record) return () => app.workspace.offref(ref) }, [app, recentVisits]) const commandPalette = useCommandPalette(app) const settings = useSettings() const accountUi = useEditionAccountUi() useEffect(() => { if (!publishContribution) return const mounted = publishContribution.mount(app, documentManager, () => accountUi.onAccountClick?.()) return () => mounted.dispose() }, [publishContribution, app, documentManager, accountUi.onAccountClick]) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) // Compute activeDocUrl from workspace (source of truth) const activeDocUrl = activeLeaf?.file ? app.vault.getUrlForFile(activeLeaf.file) ?? null : null const activeDocUrlRef = useRef(activeDocUrl) activeDocUrlRef.current = activeDocUrl // New file modal state (for ribbon-triggered creation) const [newFileModalOpen, setNewFileModalOpen] = useState(false) const editorRef = useRef(null) // Gesture system const appShellRef = useRef(null) const [gestureProgress, setGestureProgress] = useState( { active: false, edge: 'top', progress: 0 } ) // Vertical swipe callbacks — defined at top level (Rules of Hooks). // Load settings at execution time so command changes take effect immediately. // Uses executeGestureCommand (executeCommandById + Notice on failure) per #346. const handleTopSwipe = useCallback(() => { const settings = loadGestureSettings() executeGestureCommand(settings.topCommand, app.commands) }, [app.commands]) const handleBottomSwipe = useCallback(() => { const settings = loadGestureSettings() executeGestureCommand(settings.bottomCommand, app.commands) }, [app.commands]) // Resolve the command name for the gesture indicator const gestureCommandName = useMemo(() => { if (!gestureProgress.active) return '' const settings = loadGestureSettings() const cmdId = gestureProgress.edge === 'top' ? settings.topCommand : settings.bottomCommand const cmd = app.commands.getCommand(cmdId) return cmd?.name ?? cmdId }, [gestureProgress.active, gestureProgress.edge, app.commands]) // Track sidebarMode in a ref so handleSwipeAction doesn't re-create on every mode change. // The callback reads from the ref at invocation time (always current). const sidebarModeRef = useRef(state.sidebarMode) sidebarModeRef.current = state.sidebarMode // Track rightPanelCollapsed and editorMode in refs for LayoutCommandsPlugin getters. // These refs are read at callback invocation time to avoid stale closure bugs. const rightPanelCollapsedRef = useRef(state.rightPanelCollapsed) rightPanelCollapsedRef.current = state.rightPanelCollapsed const editorModeRef = useRef(state.editorMode) editorModeRef.current = state.editorMode // Real workspace-owned counts. The Bombadil invariant lane compares these // against DOM querySelector counts, so any future multi-leaf or multi-editor // scenarios must update the AppShell children and these accessors in // lockstep. The Reams main area renders exactly one editor surface for the // active leaf today, so `visibleEditorCount` mirrors the mounted-editor // surface count rather than `leaves.length` (open tabs). const computeActiveTabCount = (): number => (activeLeaf ? 1 : 0) const computeVisibleEditorCount = (): number => (activeLeaf?.file ? 1 : 0) const activeTabCountRef = useRef(computeActiveTabCount()) activeTabCountRef.current = computeActiveTabCount() const visibleEditorCountRef = useRef(computeVisibleEditorCount()) visibleEditorCountRef.current = computeVisibleEditorCount() // Reserved for future expansion: when AppShell renders all root leaves as // simultaneous editor surfaces, these refs should become `leaves.length`. void leaves // Clear stale gesture return mode when sidebar leaves 'full' via non-gesture paths // (burger menu, layout commands, programmatic setSidebarMode). The consume paths // already clear it, so this is a safety net for paths that don't go through consume. useEffect(() => { if (state.sidebarMode !== 'full') { clearGestureReturnMode() } }, [state.sidebarMode]) // Handle horizontal swipe actions from the state machine. // Gestures preserve ribbon state: opening records the prior mode in a shared // module (gesture-return-mode.ts), closing/auto-close reads it to restore. // The ribbon is never directly affected by gestures. const handleSwipeAction = useCallback((action: HorizontalSwipeAction) => { switch (action) { case 'open-sidebar': // Record where to return on close (only if not already full) if (sidebarModeRef.current !== 'full') { setGestureReturnMode(sidebarModeRef.current === 'ribbon' ? 'ribbon' : 'hidden') } setSidebarMode('full') break case 'close-sidebar': // Return to the mode that was active before the gesture opened the sidebar setSidebarMode(consumeGestureReturnMode('hidden')) break case 'open-right-panel': setRightPanelCollapsed(false) break case 'close-right-panel': setRightPanelCollapsed(true) break } }, [setSidebarMode, setRightPanelCollapsed]) // Unified swipe gesture hook — single pointer capture handles both // horizontal (sidebar/panel state machine) and vertical (command triggers) useSwipeGesture(appShellRef, { blocked: settings.isOpen || feedbackModalOpen, onSwipeAction: handleSwipeAction, sidebarMode: state.sidebarMode, rightPanelOpen: !state.rightPanelCollapsed, onTopSwipe: handleTopSwipe, onBottomSwipe: handleBottomSwipe, onProgressChange: setGestureProgress, }) // Auto-close sidebars on editor interaction at mobile viewports useAutoCloseSidebars(editorRef) // Persist workspace tabs to localStorage on layout-change and active-leaf-change useEffect(() => { return persistWorkspaceTabsOnLifecycle(app.workspace, state => { saveWorkspaceTabs(state, app.storageScope ? `reams:${app.storageScope}:workspace-tabs` : undefined) }) }, [app.workspace]) // Helper to open a document by Automerge URL. Resolves the URL to a TFile // via the vault, then delegates to workspace.openFile (which dedups by path // and creates a MarkdownView leaf when none exists). If the URL doesn't // resolve to a known file, this is a silent no-op. const openDocument = useCallback((docUrl: AutomergeUrl, _name: string) => { const file = app.vault.getFileByUrl(docUrl) if (file) { app.workspace.openFile(file) } }, [app]) const requestNewRootFile = useCallback(async () => { try { const result = await createPromptlessNote(app, app.vault.rootUrl) if (result.status === 'fallback') { setNewFileModalOpen(true) return } const url = app.vault.getUrlForFile(result.file) if (!url) throw new Error('Could not resolve the created note.') openDocumentRef.current(url, result.file.name) } catch (error) { console.error('Failed to create note:', error) new Notice('Could not create the note.') } }, [app]) // Refs for callbacks to avoid stale closures const toggleSidebarRef = useRef(toggleSidebar) const searchToggleRef = useRef(toggleSearch) const openDocumentRef = useRef(openDocument) const commandPaletteToggleRef = useRef(commandPalette.toggle) const settingsToggleRef = useRef(settings.toggle) const settingsOpenToRef = useRef(settings.openTo) const openLinkSelectionSearchRef = useRef(openLinkSelectionSearch) const setSidebarModeRef = useRef(setSidebarMode) const setRightPanelCollapsedRef = useRef(setRightPanelCollapsed) const setEditorModeRef = useRef(setEditorMode) // Keep refs in sync with latest callbacks useEffect(() => { toggleSidebarRef.current = toggleSidebar }, [toggleSidebar]) useEffect(() => { searchToggleRef.current = toggleSearch }, [toggleSearch]) useEffect(() => { openDocumentRef.current = openDocument }, [openDocument]) useEffect(() => { commandPaletteToggleRef.current = commandPalette.toggle }, [commandPalette.toggle]) useEffect(() => { settingsToggleRef.current = settings.toggle }, [settings.toggle]) useEffect(() => { openLinkSelectionSearchRef.current = openLinkSelectionSearch }, [openLinkSelectionSearch]) useEffect(() => { settingsOpenToRef.current = settings.openTo }, [settings.openTo]) useEffect(() => { setSidebarModeRef.current = setSidebarMode }, [setSidebarMode]) useEffect(() => { setRightPanelCollapsedRef.current = setRightPanelCollapsed }, [setRightPanelCollapsed]) useEffect(() => { setEditorModeRef.current = setEditorMode }, [setEditorMode]) useEffect(() => { // The Bombadil DOM extractor in tests/invariants/bombadil/extractors.ts // counts overlays as `[role="dialog"]:not([hidden])` + `.image-viewer-overlay`. // The diagnostics snapshot must use the same definition by construction, // otherwise `overlay_count_is_coherent` would compare two unrelated // numbers and fire false positives whenever any app surface used a // role="dialog" not directly tracked by a single named ref. const countOverlays = () => Array.from(document.querySelectorAll('[role="dialog"]')).filter( dialog => !dialog.hasAttribute('hidden') && dialog.getAttribute('aria-hidden') !== 'true', ).length + document.querySelectorAll('.image-viewer-overlay').length return registerAppShellInvariantProvider({ getSnapshot: () => ({ sidebarMode: sidebarModeRef.current, rightPanelCollapsed: rightPanelCollapsedRef.current, overlayCount: countOverlays(), activeDocUrl: activeDocUrlRef.current, activeTabCount: activeTabCountRef.current, visibleEditorCount: visibleEditorCountRef.current, }), setEditorMode: mode => { setEditorModeRef.current(mode) return true }, }) }, []) // chainlink #313 / #237: pending scroll-to-heading after navigation. // // The Workspace owns the pending-scroll request now (set by // openLinkText). This effect bridges it into the React rAF lifecycle: // when the workspace's pendingScroll target becomes the active leaf, // schedule a single rAF to find the heading and call // scrollActiveEditorToLine. // // We re-render this effect on every active-leaf-change AND on every // pending-scroll-set event so the consumer wakes up regardless of // which side (open vs. tab activation) lands first. const [pendingScrollVersion, setPendingScrollVersion] = useState(0) const pendingScrollGenerationRef = useRef(new Map()) const pendingScrollFramesRef = useRef(new Map()) useEffect(() => () => { for (const [frame, owned] of pendingScrollFramesRef.current) { cancelAnimationFrame(frame) if (pendingScrollGenerationRef.current.get(owned.request.filePath) === owned.generation && !app.workspace.peekPendingScroll(owned.request.filePath)) { app.workspace.setPendingScroll(owned.request) } } pendingScrollFramesRef.current.clear() pendingScrollGenerationRef.current.clear() }, [app.workspace]) useEffect(() => { const ref1 = app.workspace.on('pending-scroll-set', () => { setPendingScrollVersion(v => v + 1) }) const ref2 = app.workspace.on('active-leaf-change', () => { setPendingScrollVersion(v => v + 1) }) return () => { app.workspace.offref(ref1) app.workspace.offref(ref2) } }, [app.workspace]) useEffect(() => { // Look up the pending request keyed by the active file's path. // Per-file storage means a request for `noteB` won't be dropped // when `noteA` is active — each request waits in its own slot // until its file becomes active. (Adversary review C-NEW-1.) const activePath = activeLeaf?.file?.path if (!activePath) return // Consume the request NOW, before scheduling the rAF. This captures // ownership in this effect's closure and prevents the time-of-check // vs time-of-use race when the request slot is mutated between // the effect running and the rAF firing. (Adversary review C4.) const consumed = app.workspace.consumePendingScroll(activePath) if (!consumed) return const url = app.vault.getUrlForPath(consumed.filePath) if (!url) return const generation = (pendingScrollGenerationRef.current.get(activePath) ?? 0) + 1 pendingScrollGenerationRef.current.set(activePath, generation) // Capture scrollTop BEFORE the rAF so we can detect user-initiated // scroll changes (e.g. footnote click) between now and when the rAF // fires. Comparing before/after avoids false positives from restored // scroll positions or prior-session state. const cm6ViewNow = app.workspace.getActiveCM6View() const scrollTopBefore = cm6ViewNow ? cm6ViewNow.scrollDOM.scrollTop : 0 let id = 0 id = requestAnimationFrame(() => { pendingScrollFramesRef.current.delete(id) // An unrelated pending-scroll event must not cancel this owned request. // If navigation changed the active file, the request is stale instead. const ownsGeneration = pendingScrollGenerationRef.current.get(activePath) === generation if (app.workspace.getActiveFile()?.path !== consumed.filePath) { if (ownsGeneration) pendingScrollGenerationRef.current.delete(activePath) return } if (!ownsGeneration) return pendingScrollGenerationRef.current.delete(activePath) // Bail-out: if the user has already scrolled the editor between // when we scheduled this rAF and now (e.g. footnote click fired // before this rAF), respect their action. const cm6View = app.workspace.getActiveCM6View() if (cm6View && cm6View.scrollDOM.scrollTop !== scrollTopBefore) { return } // Two fragment shapes (chainlink #313): // `:N` → line N (0-indexed). Used when openViewState.eState.line // was supplied; e.g. footnote / back-reference plugins. // `#X` / `^Y` → heading or block id; resolved against metadata. if (consumed.fragment.startsWith(':')) { const line = Number.parseInt(consumed.fragment.slice(1), 10) if (Number.isFinite(line)) { app.workspace.scrollActiveEditorToLine(line, { behavior: 'smooth' }) } return } const cache = metadataCache.getFileCacheByUrl(url) const heading = cache?.headings ? findHeadingBySlug(cache.headings, consumed.fragment) : null if (heading) { app.workspace.scrollActiveEditorToLine( heading.position.start.line, { behavior: 'smooth' }, ) } }) pendingScrollFramesRef.current.set(id, { request: consumed, generation }) }, [pendingScrollVersion, activeLeaf, app, metadataCache]) // Create PluginLoader infrastructure (stable across renders) const pluginInfra = useMemo(() => { // Expose plugin API globally so external plugins loaded from blob URLs // can call require('obsidian') to access Plugin, Component, etc. installPluginApiGlobal() const registry = new PluginRegistry() const settingsStore = new PluginSettingsStore(app.reamsStateData!) const enabledStore = enabledStateStore ?? new EnabledStateStore() const externalPluginStore = new ExternalPluginStore() // Restore persisted external plugins BEFORE creating the loader // (so they're in the registry when enableStoredPlugins runs) externalPluginStore.restoreAll(registry) const loader = new PluginLoader(app, registry, settingsStore, enabledStore) loader.on('plugin-state-change', (id, state) => externalPluginStore.setStylesEnabled(id, state === 'active')) app.setPluginLoader(loader) return { registry, settingsStore, enabledStore, externalPluginStore, loader } }, [app, documentManager, enabledStateStore]) // Plugin instance refs (for wikilink handlers) const wikilinkPluginRef = useRef(null) const templatePluginRef = useRef(null) // Look up the templates plugin via its registered ID. Uses the shared // isValidTemplatePlugin duck-type guard to prevent TypeError on malformed // plugins. Consumers: search-create, settings UI, ribbon modal. const getActiveTemplatePlugin = useCallback((): TemplatePlugin | null => { const candidate = app.plugins.getPlugin('reams-templates') as unknown return isValidTemplatePlugin(candidate) ? candidate : null }, [app]) const templateUiActive = newFileModalOpen || searchOverlay.isOpen || settings.isOpen const { plugin: activeTemplatePlugin, version: templateStateVersion } = useReactiveTemplatePlugin({ active: templateUiActive, app, }) // Wire up manager containers once mounted useEffect(() => { app.setDocumentManager(documentManager) // Wire up the navigation handler used by WorkspaceLeaf.openFile() (the // Obsidian-compat plugin API). The handler resolves the URL via the // vault and delegates to workspace.openFile, which dedups and creates // a MarkdownView leaf when needed. app.setNavigateHandler((url, name) => openDocumentRef.current(url, name)) return () => { // Clear the handler on unmount to prevent stale closure references app.setNavigateHandler(null) } }, [app, documentManager]) // Initialize and register built-in plugins useEffect(() => { onStage?.('activating-plugins') const markBoot = (step: string) => { if (new URLSearchParams(location.search).has('benchBreakdown')) { performance.mark(`reams:boot:${step}`) } } markBoot('plugins-start') const { loader, enabledStore } = pluginInfra let cancelled = false const startupController = new AbortController() // Check if BUILT-IN plugins are already registered (handles React StrictMode double-mount) // IMPORTANT: We must check specifically for built-in IDs, not just any plugin IDs. // External plugins may be restored from localStorage before this effect runs, // so loader.getPluginIds().length > 0 could be true even on a fresh page load. const builtinsAlreadyRegistered = BUILTIN_PLUGIN_IDS.some(id => loader.isBuiltinPlugin(id)) if (builtinsAlreadyRegistered) { // Re-enable plugins that should be active (after StrictMode cleanup) const reenablePlugins = async () => { for (const id of BUILTIN_PLUGIN_IDS) { if (cancelled) return if (enabledStore.isEnabled(id) && loader.getPluginState(id) !== 'active') { try { markBoot(`plugin:${id}:start`) await loader.enablePlugin(id) } catch (error) { console.error(`Failed to re-enable built-in plugin ${id}:`, error) } finally { markBoot(`plugin:${id}:end`) } } // The note can be edited once the navigation/wikilink host is // wired. Remaining sidebar/formatting plugins keep activating. if (id === 'reams-wikilink' && !cancelled) setCorePluginsReady(true) } if (cancelled) return // Enable any external plugins that were enabled in the previous session // Open the first-party workspace before optional metadata-dependent // plugins: background indexing activates only after that opening gate. app.workspace.setLayoutReady() setPluginsFullyReady(true) await pluginInfra.externalPluginStore.restoreCatalog(pluginInfra.registry).catch(error => console.error('Failed to restore catalog plugins:', error)) if (cancelled) return await loader.enableStoredPlugins(startupController.signal) markBoot('plugins-external-done') if (cancelled) return // Restore persisted sidebar leaves (same as initial startup) await app.workspace.restoreSidebarLeaves(loadAvailableSidebarLeaves()) markBoot('plugins-sidebar-restored') // Update plugin refs. Wikilink uses the unchecked cast (no guard // helper exists yet); templates routes through getActiveTemplatePlugin // so the duck-typed shape check applies symmetrically with the // search-create flow at line 296. wikilinkPluginRef.current = loader.getPlugin('reams-wikilink') as WikilinkPlugin | null templatePluginRef.current = getActiveTemplatePlugin() // Re-signal layout ready for re-enabled plugins app.workspace.setLayoutReady() markBoot('plugins-ready') if (!cancelled) setPluginsFullyReady(true) if (!cancelled) setCorePluginsReady(true) } // StrictMode tears down the first effect synchronously. Defer activation // until the replay has settled so async onload cannot outlive its owner. queueMicrotask(() => { if (cancelled) return reenablePlugins().catch(err => { console.error('Failed to re-enable plugins:', err) if (!cancelled) setPluginsFullyReady(true) if (!cancelled) setCorePluginsReady(true) }) }) return () => { cancelled = true startupController.abort() for (const id of BUILTIN_PLUGIN_IDS) { if (loader.getPluginState(id) === 'active' || loader.getPluginState(id) === 'enabling') { loader.disablePlugin(id, { persist: false }) } } for (const id of loader.getPluginIds()) { if (loader.isBuiltinPlugin(id)) continue if (loader.getPluginState(id) === 'active' || loader.getPluginState(id) === 'enabling') { loader.disablePlugin(id, { persist: false }) } loader.cancelPendingEnable(id) } wikilinkPluginRef.current = null templatePluginRef.current = null } } // Create plugin instances with their custom options. // QuickAccessPlugin is constructed and registered before FileTreePlugin // so its `onLayoutReady` callback fires first — making it // `workspace.leftLeaves[0]` (the top left-rail tab). const quickAccessPlugin = new QuickAccessPlugin(app) const typesBrowserPlugin = new TypesBrowserPlugin(app) const fileTreePlugin = new FileTreePlugin(app, { onToggleSidebar: () => toggleSidebarRef.current(), }) const quickSearchPlugin = new QuickSearchPlugin(app, { onOpenSearch: () => searchToggleRef.current(), }) // metadataCache and searchService are wired into `app` by SearchProvider // (src/services/SearchContext.tsx). Built-in plugins read from // this.app.metadataCache and this.app.search — same path as external plugins. // // chainlink #313: built-ins no longer take `onNavigate` callbacks. // They navigate via `app.workspace.openLinkText` — the same public // API external plugins use. The React layer's responsibility is // narrowed to: (a) wiring `setNavigateHandler` so leaf.openFile // bridges into React's tab system, and (b) consuming workspace // `pendingScroll` requests after open (see effect below). const wikilinkPlugin = new WikilinkPlugin(app, { onToggleBacklinks: () => { setRightPanelCollapsedRef.current(!rightPanelCollapsedRef.current) }, }) wikilinkPluginRef.current = wikilinkPlugin const dailyNotesPlugin = new DailyNotesPlugin(app) const createFilePlugin = new CreateFilePlugin(app, { onRequestNewFile: () => { void requestNewRootFile() }, }) const wordCountPlugin = new WordCountPlugin(app) const wordHighlighterPlugin = new WordHighlighterPlugin(app) const highlightPlugin = new HighlightPlugin(app) const documentInfoPlugin = new DocumentInfoPlugin(app) const templatePlugin = new TemplatePlugin(app) templatePluginRef.current = templatePlugin const ofmtShimPlugin = new OfmtShimPlugin(app) const builtinFormattingPlugin = new BuiltinFormattingPlugin( app, (onSelect, initialQuery) => openLinkSelectionSearchRef.current(onSelect, initialQuery), ) // LayoutCommandsPlugin replaces the four hooks (useLayoutCommands, useEditorCommands, // useCommandPalette command registration, useSettings command registration) that // previously used the fake 'reams-core' plugin ID. Callbacks read from refs at // execution time to always get current React state. IMPORTANT: getters must also // read from refs to avoid stale closure bugs (P0 fix from adversary review). const imageViewerPlugin = new ImageViewerPlugin(app, IMAGE_VIEWER_MANIFEST) const layoutCommandsPlugin = new LayoutCommandsPlugin(app, { setSidebarMode: (mode) => setSidebarModeRef.current(mode), getSidebarMode: () => sidebarModeRef.current, setRightPanelCollapsed: (collapsed) => setRightPanelCollapsedRef.current(collapsed), getRightPanelCollapsed: () => rightPanelCollapsedRef.current, setEditorMode: (mode) => setEditorModeRef.current(mode), getEditorMode: () => editorModeRef.current, toggleCommandPalette: () => commandPaletteToggleRef.current(), toggleSettings: () => settingsToggleRef.current(), }) // Register ALL built-ins unconditionally (so they appear in settings) // This registers them as 'disabled' - enabling happens next. // QuickAccess registered first → enabled first → top tab. (See // BUILTIN_PLUGIN_IDS comment.) loader.registerBuiltinInstance(quickAccessPlugin) loader.registerBuiltinInstance(typesBrowserPlugin) loader.registerBuiltinInstance(fileTreePlugin) loader.registerBuiltinInstance(quickSearchPlugin) loader.registerBuiltinInstance(wikilinkPlugin) loader.registerBuiltinInstance(dailyNotesPlugin) loader.registerBuiltinInstance(createFilePlugin) loader.registerBuiltinInstance(wordCountPlugin) loader.registerBuiltinInstance(wordHighlighterPlugin) loader.registerBuiltinInstance(highlightPlugin) loader.registerBuiltinInstance(documentInfoPlugin) loader.registerBuiltinInstance(ofmtShimPlugin) loader.registerBuiltinInstance(templatePlugin) loader.registerBuiltinInstance(builtinFormattingPlugin) loader.registerBuiltinInstance(layoutCommandsPlugin) loader.registerBuiltinInstance(imageViewerPlugin) // Auto-enable built-in plugins: // - On first run: enable all built-ins // - On subsequent runs: enable any NEW built-ins the user hasn't seen yet // (so new features are on by default, but user-disabled plugins stay off) for (const id of BUILTIN_PLUGIN_IDS) { if (!enabledStore.hasBeenSeen(id)) { enabledStore.setEnabled(id, true) } } // Enable plugins that are marked enabled const enablePlugins = async () => { for (const id of BUILTIN_PLUGIN_IDS) { if (cancelled) return if (enabledStore.isEnabled(id)) { try { markBoot(`plugin:${id}:start`) await loader.enablePlugin(id) } catch (error) { console.error(`Failed to enable built-in plugin ${id}:`, error) } finally { markBoot(`plugin:${id}:end`) } } if (id === 'reams-wikilink' && !cancelled) setCorePluginsReady(true) } if (cancelled) return // Enable any external plugins that were enabled in the previous session. // enableStoredPlugins() iterates ALL enabled IDs in the EnabledStateStore // and enables any that are in the registry. Built-ins are already handled // above (and enablePlugin is idempotent for active plugins), so this // effectively enables only the external plugins. app.workspace.setLayoutReady() setPluginsFullyReady(true) await pluginInfra.externalPluginStore.restoreCatalog(pluginInfra.registry).catch(error => console.error('Failed to restore catalog plugins:', error)) if (cancelled) return await loader.enableStoredPlugins(startupController.signal) markBoot('plugins-external-done') if (cancelled) return // Restore persisted sidebar leaves. This must happen after all plugins // have registered their view types (via onload/registerView) but before // setLayoutReady(). It recreates sidebar tabs for external plugins that // only create their leaf in onUserEnable() (called once on first install) // and rely on workspace layout persistence across sessions. // Idempotent: skips if a leaf of the same view type already exists on // that side (built-in plugins create their own in onLayoutReady). await app.workspace.restoreSidebarLeaves(loadAvailableSidebarLeaves()) markBoot('plugins-sidebar-restored') // Signal that the layout is ready. Plugins that deferred work via // workspace.onLayoutReady() (e.g. FileTreePlugin creating its sidebar // leaf) will have their callbacks fired here, after all plugins are // enabled and DOM containers are wired up. app.workspace.setLayoutReady() markBoot('plugins-ready') if (!cancelled) setPluginsFullyReady(true) if (!cancelled) setCorePluginsReady(true) } queueMicrotask(() => { if (cancelled) return enablePlugins().catch(err => { console.error('Failed to enable plugins:', err) if (!cancelled) setPluginsFullyReady(true) if (!cancelled) setCorePluginsReady(true) }) }) return () => { cancelled = true startupController.abort() app.workspace.pauseDeferredViews() // Disable all active plugins on cleanup. // Use persist: false to avoid marking them as permanently disabled — // this is just React cleanup, not a user-initiated disable. for (const id of BUILTIN_PLUGIN_IDS) { if (loader.getPluginState(id) === 'active' || loader.getPluginState(id) === 'enabling') { loader.disablePlugin(id, { persist: false }) } } // Also disable external plugins (without persisting) for (const pluginId of loader.getPluginIds()) { if (!loader.isBuiltinPlugin(pluginId) && (loader.getPluginState(pluginId) === 'active' || loader.getPluginState(pluginId) === 'enabling')) { loader.disablePlugin(pluginId, { persist: false }) } if (!loader.isBuiltinPlugin(pluginId)) loader.cancelPendingEnable(pluginId) } wikilinkPluginRef.current = null // NOTE: Do NOT call externalPluginStore.cleanup() here. templatePluginRef.current = null // Blob URLs must survive React StrictMode double-mount cycles. // They live as long as the pluginInfra useMemo (which is stable // across the component's lifetime). Cleanup happens on page unload. } // metadataCache and searchService are intentionally NOT dependencies: // SearchProvider wires them onto `app` once at startup (before this // effect runs), and they don't change for the component's lifetime. // Built-in plugins read from this.app.metadataCache / this.app.search. // eslint-disable-next-line react-hooks/exhaustive-deps }, [app, pluginInfra]) // Follow remote preference edits after startup. Local enable/disable events // already have their desired lifecycle state; the queue coalesces changes // arriving while a plugin's async onload is still running. useEffect(() => { if (!enabledStateStore) return let disposed = false let running = false let pending = false const reconcile = async () => { if (running) { pending = true; return } running = true try { do { pending = false for (const id of pluginInfra.loader.getPluginIds()) { if (disposed) return const active = pluginInfra.loader.getPluginState(id) === 'active' if (enabledStateStore.isEnabled(id) && !active) { try { await pluginInfra.loader.enablePlugin(id) } catch (error) { console.error(`Failed to enable synced plugin ${id}:`, error) } } else if (!enabledStateStore.isEnabled(id) && active) { pluginInfra.loader.disablePlugin(id, { persist: false }) } } } while (pending && !disposed) } finally { running = false } } const unsubscribe = enabledStateStore.subscribe(() => { void reconcile() }) return () => { disposed = true; unsubscribe() } }, [enabledStateStore, pluginInfra]) // Sync React layout state with workspace-owned sidebar collapsed state. // Workspace is the source of truth for collapsed/expanded state since #331. // When workspace fires '*-sidebar-collapsed-change', React syncs its local state. // // Design note: The left sidebar has three modes ('full', 'ribbon', 'hidden'). // Workspace only tracks whether sidebar CONTENT is visible (collapsed boolean). // 'ribbon' and 'hidden' both map to collapsed=true — ribbon visibility is a // pure UI concern that React owns. So: // - When workspace says "expand" (collapsed=false): set sidebarMode='full' // - When workspace says "collapse" (collapsed=true): hide the overlay on // mobile; desktop keeps its user-selected 'ribbon' or 'hidden' mode // // Note: 'reveal-panel' is still fired by revealLeaf() for backward compat with // external plugins, but AppShell no longer listens to it — the new events are // the canonical path. Sidebar collapsed events are Reams-novel (app.reamsEvents). useEffect(() => { const leftRef = app.reamsEvents.on('left-sidebar-collapsed-change', (collapsed) => { if (!collapsed) setSidebarMode('full') else if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) { setSidebarMode('hidden') } }) const rightRef = app.reamsEvents.on('right-sidebar-collapsed-change', (collapsed) => { setRightPanelCollapsed(collapsed) }) return () => { app.reamsEvents.offref(leftRef) app.reamsEvents.offref(rightRef) } }, [app, setRightPanelCollapsed, setSidebarMode]) // Sync React layout state TO workspace when React state changes (two-way binding). // This handles: // 1. Initial mount: React loads from localStorage, workspace needs to know // 2. User interactions: burger menu, gestures, layout commands change React state // The workspace setters have guards (if same value, no-op) to prevent infinite loops. useEffect(() => { // sidebarMode 'full' = expanded (collapsed=false), 'ribbon'/'hidden' = collapsed (collapsed=true) const leftCollapsed = state.sidebarMode !== 'full' app.workspace.setLeftSidebarCollapsed(leftCollapsed) }, [app.workspace, state.sidebarMode]) useEffect(() => { app.workspace.setRightSidebarCollapsed(state.rightPanelCollapsed) }, [app.workspace, state.rightPanelCollapsed]) // Listen for 'tag-click' events on app.reamsEvents to open search with tag query. // Tags in body decorations, frontmatter widget, and sidebar panel all // fire the same event for unified click-to-search behavior. useEffect(() => { const ref = app.reamsEvents.on('tag-click', (tag) => { setLinkSearchSelect(null) searchOverlay.openWithQuery(tag) }) return () => app.reamsEvents.offref(ref) }, [app.reamsEvents, searchOverlay]) // Persist sidebar leaf manifest (which view types exist on which side) // so external plugin tabs survive page reloads. Debounced to avoid // writing on every rapid leaf creation during startup. // // NOTE: We do NOT flush on React cleanup because StrictMode unmount // disables plugins (detaching leaves) before this cleanup runs. Flushing // at that point would save an empty manifest and lose the data. // Instead, we rely on the debounced save (which fires during normal // operation) and a beforeunload handler for page unloads. useEffect(() => { let timer: ReturnType | undefined let cleaningUp = false const save = () => { // During React cleanup (StrictMode unmount), plugins are disabled // and leaves detached before any pending timers fire. Skip saves // in that state to avoid overwriting the real manifest with empty. if (cleaningUp) return savePersistedSidebarLeaves(app.workspace.getSidebarLeafManifest()) } const ref = app.workspace.on('layout-change', () => { if (cleaningUp) return clearTimeout(timer) timer = setTimeout(save, 500) }) const handleBeforeUnload = () => save() window.addEventListener('beforeunload', handleBeforeUnload) return () => { cleaningUp = true clearTimeout(timer) window.removeEventListener('beforeunload', handleBeforeUnload) app.workspace.offref(ref) } }, [app.workspace]) // Keyboard shortcut: Cmd/Ctrl+W to close active tab useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'w') { const activeLeaf = app.workspace.activeLeaf // Only close if it's a main-area leaf (not sidebar) if (activeLeaf && app.workspace.getPanelSide(activeLeaf) === null) { e.preventDefault() void app.workspace.detachLeafAfterPreTeardown(activeLeaf).catch(error => { console.error('Failed to close tab:', error); new Notice('Tab stayed open because pending edits could not be saved.') }) } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [app.workspace]) // Auto-close tabs when their file is deleted via Vault. // Now uses workspace leaves instead of LayoutContext tabs. useEffect(() => { const handleDelete = (file: import('../plugin/files').TAbstractFile) => { const deletedUrl = app.vault.getUrlForFile(file) if (!deletedUrl) return // Find and detach any leaves showing this file const leavesToDetach: WorkspaceLeaf[] = [] app.workspace.iterateRootLeaves(leaf => { const leafUrl = leaf.file ? app.vault.getUrlForFile(leaf.file) : null if (leafUrl === deletedUrl && !leaf.isFileReplacementPreserved(leaf.file ?? file)) { leavesToDetach.push(leaf) } }) for (const leaf of leavesToDetach) { leaf.detach() } } const ref = app.vault.on('delete', handleDelete) return () => app.vault.offref(ref) }, [app.vault, app.workspace]) // Display title resolver for fuzzy search const getDisplayTitleForSearch = useCallback((url: AutomergeUrl, name: string): string | undefined => { const title = metadataCache.getDisplayTitle(url) const basename = name.replace(/\.md$/, '') return title && title !== basename ? title : undefined }, [metadataCache]) // Handle file selection from search overlay const handleSearchSelect = useCallback((url: AutomergeUrl, name: string) => { if (linkSearchSelect) { const file = app.vault.getFileByUrl(url) if (file) linkSearchSelect(file) else new Notice('The selected note no longer exists') return } openDocumentRef.current(url, name) }, [app.vault, linkSearchSelect]) const handleExternalSearchSelect = useCallback((_result: import('../services/search-service').ExternalSearchResult) => false, []) const handleSearchClose = useCallback(() => { setLinkSearchSelect(null) searchOverlay.close() }, [searchOverlay.close]) // Adapter from React state to the pure search-create logic in // `src/layout/search-create.ts`. The actual preview-resolution rules // (template filename pattern, validation, folder-mode rejection) live // there; this callback only forwards the active templates plugin. const resolveSearchCreateRequest = useCallback((query: string): SearchCreatePreview | null => { return resolveSearchCreatePreview(query, getActiveTemplatePlugin()) }, [getActiveTemplatePlugin]) const [searchCreatePreview, setSearchCreatePreview] = useState(null) const handleSearchQueryChange = useCallback((query: string) => { setSearchCreatePreview(resolveSearchCreateRequest(query)) }, [resolveSearchCreateRequest]) // Re-evaluate the preview when the templates plugin enables/disables mid- // overlay. `templateStateVersion` bumps via `useReactiveTemplatePlugin` // whenever the plugin's enabled state changes, so the preview updates // from "plain file" to "templated filename" (or vice versa) without the // user having to re-type their query. useEffect(() => { setSearchCreatePreview(prev => (prev ? resolveSearchCreateRequest(prev.rawQuery) : prev)) }, [resolveSearchCreateRequest, templateStateVersion]) // Wire the pure executeSearchCreate to the live vault, templates plugin, // and tab-opener. The actual create flow (existing-file short-circuit, // template vs blank create, recovery on duplicate, error reporting) is // implemented in `src/layout/search-create.ts`; this callback just // injects the live dependencies. const handleSearchCreate = useCallback(async (createRequest: SearchCreatePreview) => { return executeSearchCreate(createRequest, { resolveSearchCreateRequest, getActiveTemplatePlugin, getFileByPath: (path) => app.vault.getFileByPath(path), createBlankFile: (path) => app.vault.create(path, ''), getUrlForPath: (path) => app.vault.getUrlForPath(path), openDocument: (url, name) => openDocumentRef.current(url, name), // Production wiring is plain console.error. If we add a global // notice/toast surface in the future, upgrade this to surface // the error in the UI. onError: (err) => { console.error('Failed to create file from search:', err) }, }) }, [app.vault, getActiveTemplatePlugin, resolveSearchCreateRequest]) // Wikilink handlers for the editor. // // chainlink #313: editor wikilink clicks delegate to the wikilink // plugin (which itself delegates to `Workspace.openLinkText`). When // the wikilink plugin is disabled, we still want the editor's // wikilink-click to navigate — `Workspace.openLinkText` is a // workspace-level primitive that works whether or not the plugin // is loaded. Adversary review m-NEW-5 flagged this regression. // `resolveLink` (existence check) routes through the wikilink plugin // since it has no public workspace equivalent — when the plugin is // disabled it returns `true` (treat-as-resolved fallback) so the // editor doesn't strike-through every wikilink in the doc. // // Memoized so MainAreaContext.Provider's value identity is actually // stable across renders that don't touch the wikilink plugin or the // app reference. Without this useMemo, the fresh object literal would // force every MarkdownLeafBinding consumer to re-read the context // on every AppShell render (R11). const wikilinkHandlers: WikilinkHandlers = useMemo(() => ({ onNavigateWikilink: (target: string) => { const plugin = wikilinkPluginRef.current if (plugin) { plugin.navigateToLink(target) return } // Plugin disabled: navigate via the public workspace API. const sourcePath = app.workspace.getActiveFile()?.path ?? '' app.workspace.openLinkText(target, sourcePath, false).catch((err) => { console.error(`Failed to navigate to wikilink "${target}":`, err) }) }, resolveLink: (target: string) => wikilinkPluginRef.current?.resolveLink(target) ?? true, }), [app]) // MainAreaContext provider value — exposes everything the MarkdownLeafBinding // (and any other future ReactItemView rendered by the dispatcher) needs. // Must live inside AppShell because `wikilinkHandlers` depends on // `wikilinkPluginRef.current`, populated by AppShell's plugin-loader effect. const mainAreaContextValue: MainAreaContextValue = useMemo(() => ({ wikilinkHandlers, editorMode: editorMode ?? 'live-preview', formattingActionManager: app.reamsFormattingActions, vault: app.vault, pluginApp: app, onSaveStatusChange, onConcurrentTabChange, onInitialViewReady: markInitialViewReady, }), [wikilinkHandlers, editorMode, app, onSaveStatusChange, onConcurrentTabChange, markInitialViewReady]) // Subscribe to workspace 'active-leaf-change' to get the editor context // from the active leaf's MarkdownView. This enables editorCallback commands // (e.g., Wikipedia's "Get Wikipedia for...") to appear in the command palette. // // Returns { editor, view } so command-registry can pass both to editorCallback // matching Obsidian's signature: editorCallback(editor, view). // // useSyncExternalStore requires getSnapshot to return the same reference when // the value hasn't changed (Object.is comparison). We cache the result in a // ref and only create a new object when the editor reference changes. const editorContextCache = useRef<{ editor: unknown; view: unknown } | null>(null) const editorContext = useSyncExternalStore( useCallback((onStoreChange: () => void) => { const ref = app.workspace.on('active-leaf-change', onStoreChange) return () => app.workspace.offref(ref) }, [app.workspace]), useCallback(() => { const leaf = app.workspace.activeLeaf const view = leaf?.view if (view && 'editor' in view) { const editor = (view as { editor: unknown }).editor const cached = editorContextCache.current if (cached && cached.editor === editor && cached.view === view) { return cached } const ctx = { editor, view } editorContextCache.current = ctx return ctx } editorContextCache.current = null return null }, [app.workspace]) ) // Commands for command palette const commands = commandPalette.getCommands(editorContext) // Handler for creating a file from the ribbon's new file modal const handleCreateFileFromRibbon = useCallback(async (name: string) => { // Check existence first — vault.create() throws on duplicates const existing = app.vault.getFileByPath(name) if (existing) { const url = app.vault.getUrlForPath(name) if (url) openDocumentRef.current(url, existing.name) return } const file = await app.vault.create(name, name.toLowerCase().endsWith('.base') ? newBaseYaml() : '') const url = app.vault.getUrlForPath(file.path) if (url) { openDocumentRef.current(url, file.name) } }, [app.vault]) // Handler for creating a file from template via the ribbon modal const handleCreateFileFromTemplateRibbon = useCallback(async (name: string, templatePath: string) => { const templatePlugin = getActiveTemplatePlugin() // Check existence first — createFileFromTemplate calls vault.create() which throws on duplicates const existing = app.vault.getFileByPath(name) if (existing) { const url = app.vault.getUrlForPath(name) if (url) openDocumentRef.current(url, existing.name) return } const file = templatePlugin ? await templatePlugin.createFileFromTemplate(name, { templatePath }) : await app.vault.create(name, '') const url = app.vault.getUrlForPath(file.path) if (url) { openDocumentRef.current(url, file.name) } }, [app.vault, getActiveTemplatePlugin]) // Handler for uploading an image from the ribbon's new file modal const handleUploadImageFromRibbon = useCallback(async (name: string, data: Uint8Array, mimeType: string) => { // Check existence — vault.createBinary() throws on duplicates if (app.vault.getFileByPath(name)) return await app.vault.createBinary(name, data, mimeType) }, [app.vault]) // Handler for adopting an external Automerge folder at the vault root. // Mirrors FileTree's old handleAdoptFolderAtRoot. Routed here because the // root-level CreateItemModal lives in AppShell now (and is triggered by // the chrome-row "+" button in the file-tree panel, the ribbon's "New // file" icon, and the `reams-create-file:create-file` command). const handleAdoptFolderFromRibbon = useCallback( async (intermediateSegments: string[], url: AutomergeUrl) => { await app.vault.adoptFolderByUrl(app.vault.rootUrl, intermediateSegments, url) }, [app.vault], ) // Determine sidebar visibility based on mode. // On desktop, hidden sidebar uses width: 0 via CSS [data-hidden="true"]. // On mobile, hidden sidebar uses transform: translateX for slide animation, // so we always pass the configured width to keep the element sized for animation. const ribbonHidden = state.sidebarMode === 'hidden' const sidebarHidden = state.sidebarMode !== 'full' const showResizeHandle = state.sidebarMode === 'full' // Compute left/right offsets for fixed-position elements (e.g., // FormattingToolbar) that need to avoid overlapping the ribbon, sidebar, // and right panel on desktop. Mobile uses overlay layout, so these only // apply on desktop (CSS media query). Pure helpers in layout-state.ts. #213 const toolbarLeftOffset = computeToolbarLeftOffset(state.sidebarMode, state.sidebarWidth) const toolbarRightOffset = computeToolbarRightOffset(state.rightPanelCollapsed, state.rightPanelWidth) const ribbonWidthOffset = ribbonHidden ? '0px' : 'var(--icon-column-width)' const toolbarChrome = useSyncExternalStore( useCallback((cb: () => void) => app.reamsToolbarChrome.subscribe(cb), [app]), useCallback(() => app.reamsToolbarChrome.getSnapshot(), [app]), ) return (
{ await app.awaitRecordEdits() if (!await app.flushAllDocumentSync()) throw new Error('Could not save open notes. Try again.') await documentManager.flushPreparedTextFirstDocuments() }} />
{/* Ribbon stays mounted to preserve plugin icons attached to the DOM */}
{/* Fuzzy Search Overlay */} recentVisits.list(app.vault) : searchOverlay.getRecentFiles} getDisplayTitle={getDisplayTitleForSearch} initialQuery={searchOverlay.initialQuery} /> {/* Command Palette */} commandPalette.executeCommand(cmd, editorContext)} commands={commands} onSearch={(query) => commandPalette.searchCommands(query, editorContext)} /> {/* Settings Modal */} {FeedbackModalComponent && ( setFeedbackModalOpen(false)} onSignIn={() => { setFeedbackModalOpen(false) accountUi.onAccountClick?.() }} /> )} {accountUi.accountCard} {/* Root-level "New Item" modal. Triggered by: - The ribbon's "New file" icon (CreateFilePlugin) - The chrome-row "+" button in the file-tree panel (FileTreeView.getHeaderActions) - The `reams-create-file:create-file` command palette entry All three paths dispatch via the same command, which toggles `newFileModalOpen` here. */} setNewFileModalOpen(false)} onCreateFolder={async (name: string) => { await app.vault.createFolder(name) }} onCreateFile={handleCreateFileFromRibbon} onAdoptFolder={handleAdoptFolderFromRibbon} onUploadImage={handleUploadImageFromRibbon} templates={activeTemplatePlugin?.getTemplates() ?? []} defaultTemplate={activeTemplatePlugin?.getDefaultTemplate() ?? ''} onCreateFileFromTemplate={handleCreateFileFromTemplateRibbon} filenamePattern={activeTemplatePlugin?.getFilenamePattern()} filenamePreview={activeTemplatePlugin?.expandFilenamePattern()} expandFilenamePattern={activeTemplatePlugin?.getFilenamePattern().includes('{{title}}') ? title => activeTemplatePlugin.expandFilenamePattern(title) : undefined} /> {/* Gesture pull indicator (vertical swipes) */}
) }