// src/plugin/app.ts import type { Vault } from './vault' import type { Workspace } from './workspace' import type { DocumentManager, DocumentId } from '../services/document-manager' import type { MetadataCache } from '../services/metadata' import type { SearchService } from '../services/search-service' import type { BackgroundIndexer } from '../services/background-indexer' import type { TypedNodeCatalog } from '../types-records/typed-node-catalog' import type { TFile } from './files' import type { Plugin } from './plugin' import { CommandRegistry, HotkeyManager, isMacPlatform } from './commands' import { RibbonActionManager, StatusBarActionManager, FormattingActionManager, ToolbarChromeStore } from './ui' import { FileManager, createFileManager, setFileManagerMetadataCache } from './file-manager' import { Keymap, Scope, SecretStorage, RenderContext, _destroyScope } from './obsidian-stubs' import { ImageResolver } from '../services/image-resolver' import { UrlRouter } from '../services/url-router' import type { PluginState, PluginLoaderEvents } from './loader/types' import { Events, type EventRef } from './events' import { ReamsEvents } from './reams/events' import type { UserEvent } from './types' import { getCoreSemanticRegistry } from '../format-preservation' import type { ConstructSpec } from '../format-preservation' import { setRecordField, type RecordWriteOutcome, type SetFieldIntent, type TextContribution } from '../types-records/record-write-command' import type { RootFrontmatterRecords } from '../types-records/root-frontmatter-records' import type { TypeCatalog } from '../types-records/type-catalog' import { isTypesRecordsEnabled } from '../types-records/feature-setting' import type { AutomergeUrl } from '../storage/document-runtime-contract' import type { VaultStateData } from '../services/vault-state-data' /** @internal Host-internal callbacks for currently open document editors. */ export interface DocumentSyncCallbacks { readonly flush: () => Promise readonly prepareExternalWrite: () => Promise<(() => void) | null> readonly project: (contribution: TextContribution) => Promise readonly reload: () => Promise } /** @internal Host-internal input for the canonical record edit pipeline. */ export interface AppRecordEditInput { readonly documents: DocumentManager readonly roots: RootFrontmatterRecords readonly catalog: TypeCatalog readonly documentId: DocumentId readonly documentPath: string readonly enabled: boolean readonly intent: SetFieldIntent } /** * Warn-once set for App stub methods. * Keys are method identifiers like 'foldManager.save'. */ const warnedAppStubs = new Set() /** Emit a console.warn once per key per page load. */ function warnOnceApp(key: string, message: string): void { if (warnedAppStubs.has(key)) return warnedAppStubs.add(key) console.warn(message) } /** * Reset warn-once state for App stubs (for testing only). * @internal */ export function _resetAppWarnedStubs(): void { warnedAppStubs.clear() } /** * Map from Obsidian internal plugin ids to Reams built-in plugin ids. * Used by internalPlugins.getPluginById() to resolve Obsidian-style * internal plugin lookups to their Reams equivalents. * * This enables compatibility with plugins like Periodic Notes that check * app.internalPlugins.getPluginById('daily-notes').instance.options */ const OBSIDIAN_TO_REAMS_PLUGIN_ID: Record = { 'daily-notes': 'reams-daily-notes', 'templates': 'reams-templates', 'file-explorer': 'reams-file-tree', 'global-search': 'reams-quick-search', 'switcher': 'reams-quick-search', } /** * Adapter shape returned by internalPlugins.getPluginById(). * Matches Obsidian's internal plugin instance shape. * @internal */ interface InternalPluginAdapter { enabled: boolean instance: { options: Record } | null enable(): void disable(save?: boolean): void } declare global { interface Window { app?: App } } /** * Interface for PluginLoader methods used by the App.plugins wrapper * @internal */ interface PluginLoaderLike { getPlugin(id: string): Plugin | null getPluginIds(): string[] getPluginState(id: string): PluginState | null enablePlugin(id: string, options?: { userInitiated?: boolean }): Promise disablePlugin(id: string): void // Events (Reams-internal on/offref pattern for host React components) on( name: K, callback: (...args: PluginLoaderEvents[K]) => unknown, ctx?: unknown ): EventRef offref(ref: EventRef): void } /** * App class - main application instance. * Provides access to vault, workspace, and other services. * * @public * @since 0.9.7 */ export class App { /** * The vault instance. * @public * @since 0.9.7 */ vault: Vault /** * The workspace instance. * @public * @since 0.9.7 */ workspace: Workspace /** * Metadata cache for parsed file metadata. * @public * @since 0.9.7 */ metadataCache!: MetadataCache /** @internal Search service (set after App construction) */ private _searchService: SearchService | null = null private _typedNodeCatalog: TypedNodeCatalog | null = null private _backgroundIndexer: BackgroundIndexer | null = null private readonly _typedNodeCatalogListeners = new Set<() => void>() private _onDocumentContentChanged: ((file: TFile, contribution?: TextContribution) => void) | null = null /** * Central command registry. * @internal Reams-internal API — not part of Obsidian's App. */ readonly commands: CommandRegistry /** * Global hotkey manager. * @internal Reams-internal API — not part of Obsidian's App. */ readonly hotkeys: HotkeyManager /** * Ribbon icon manager (Reams-novel API). * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsRibbon: RibbonActionManager /** * Status bar item manager (Reams-novel API). * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsStatusBar: StatusBarActionManager /** * Formatting toolbar action manager (Reams-novel API). * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsFormattingActions: FormattingActionManager /** * Runtime mobile/toolbar chrome state store (Reams-novel API). * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsToolbarChrome: ToolbarChromeStore /** * FileManager instance for file operations. * @public * @since 0.11.0 */ fileManager: FileManager /** * Image resolver (vault → blob URL); intentionally NOT disposed on App.destroy * (see destroy() comment). Reams-novel API. * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsImageResolver: ImageResolver /** * URL ↔ workspace bridge for typed documents (#411). Owned by App so * its event subscriptions survive React StrictMode remounts (the * `App` instance is preserved across them via `useMemo`). * * `attach()`/`detach()` are called from `reattach()` and `destroy()` * respectively so the listeners come and go in lockstep with the * other lifecycle-bound state (hotkeys, etc.). * * @internal Reams-internal API — not part of Obsidian's App. */ readonly urlRouter: UrlRouter /** * Reams-novel events bus. * * Subscribe to Reams-specific events here instead of on `workspace`. Events: * - `panel-leaf-change` — sidebar tab selection changed * - `reveal-panel` — sidebar panel revealed * - `left-sidebar-collapsed-change` — left sidebar state changed * - `right-sidebar-collapsed-change` — right sidebar state changed * - `view-registered` — view type registered * - `view-unregistered` — view type unregistered * - `editor-extensions-changed` — CM6 extensions changed * - `editor-suggest-registered` — EditorSuggest registered * - `editor-suggest-unregistered` — EditorSuggest unregistered * * @example * ```ts * const ref = app.reamsEvents.on('panel-leaf-change', (side, leaf) => { * console.log(`Panel ${side} active leaf is now:`, leaf) * }) * // Cleanup: * app.reamsEvents.offref(ref) * ``` * * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsEvents: ReamsEvents /** * Global keyboard scope. * @public * @since 0.9.7 */ scope: Scope /** * Secret storage for sensitive credentials. * @public * @since 1.11.4 */ secretStorage: SecretStorage /** * Keymap manager for scope and modifier key handling. * @public * @since 0.9.7 */ keymap: Keymap /** * Utility functions for rendering Values within the app. * @public * @since 1.10.0 */ renderContext: RenderContext /** @internal Plugin manager wrapper (set after loader is available) */ private _pluginLoader: PluginLoaderLike | null = null /** * No-op Events instance for plugin subscriptions when no loader is set. * Ensures EventRef objects returned by app.plugins.on() are always valid * (ref.e is a proper Events instance), even during early initialization * before the PluginLoader is wired up. * @internal */ private readonly _noopPluginEvents = new Events() /** * Cache for internal plugin adapters. Ensures stable adapter object identity: * getPluginById('daily-notes') === getPluginById('daily-notes') * * Note: The adapter's `instance` property is refreshed on each call to * reflect current settings, so `adapter.instance.options` may differ * across calls. The adapter reference itself (with enable/disable methods) * remains stable. * @internal */ private readonly _internalPluginAdapterCache = new Map() /** * Plugin wrapper providing Obsidian-compatible interface. * @internal Reams-internal API — not part of Obsidian's App. */ readonly plugins: { getPlugin(id: string): Plugin | null enablePlugin(id: string, options?: { userInitiated?: boolean }): Promise disablePlugin(id: string): void readonly plugins: Record /** Set of active plugin IDs. Obsidian-compatible — plugins like Recent Files * check `app.plugins.enabledPlugins.has(id)` for inter-plugin integration. */ readonly enabledPlugins: Set /** * Subscribe to plugin events. * * @internal Host-internal API for built-in React components (FileTree, * useReactiveTemplatePlugin) to subscribe to plugin state changes. * External plugins should not rely on this — use plugin-to-plugin * communication via `app.plugins.getPlugin()` and settings/events * on the target plugin instead. * * @param name Event name (currently only 'plugin-state-change') * @param callback Handler invoked with (pluginId, newState, oldState) * @returns EventRef for cleanup via offref() */ on( name: K, callback: (...args: PluginLoaderEvents[K]) => unknown ): EventRef /** * Unsubscribe from plugin events using the EventRef returned by on(). * @internal See on() for usage notes. */ offref(ref: EventRef): void } /** * Fold manager stub. * * Obsidian's foldManager persists editor fold state. Periodic Notes and * other plugins check for this. Reams does not persist fold state to the * vault -- folds are managed by CM6's built-in fold state which is * ephemeral per editor instance. * @internal Reams-internal API — not part of Obsidian's App. */ readonly foldManager: { save(file: unknown): void load(file: unknown): unknown } = { save: () => { warnOnceApp( 'foldManager.save', '[Reams] app.foldManager.save() is not implemented. Fold state is not persisted.' ) }, load: () => { warnOnceApp( 'foldManager.load', '[Reams] app.foldManager.load() is not implemented. Fold state is not persisted.' ) return null }, } /** * Custom CSS stub — Periodic Notes reads theme. * @internal Reams-internal API — not part of Obsidian's App. */ readonly customCss: { theme: string } = { theme: '', } /** * Internal plugins API for Obsidian compatibility. * * Maps Obsidian internal plugin ids (e.g. 'daily-notes', 'templates') to * their Reams built-in equivalents. Returns an adapter with stable identity * per id, exposing the underlying Reams plugin's settings as `instance.options`. * * For unknown ids not in the mapping, returns null (matches Obsidian behavior). * For mapped ids whose Reams plugin isn't loaded, also returns null. * @internal Reams-internal API — not part of Obsidian's App. */ readonly internalPlugins: { getPluginById(id: string): InternalPluginAdapter | null getEnabledPluginById(id: string): InternalPluginAdapter | null } /** * Drag manager stub. * * Obsidian's dragManager enables drag-and-drop of files between panes and * to external applications. Recent Files and other plugins call dragFile() * to initiate drags. Reams does not yet support inter-pane drag-and-drop. * @internal Reams-internal API — not part of Obsidian's App. */ readonly dragManager: { dragFile(evt: DragEvent, file: unknown): unknown onDragStart(evt: DragEvent, dragData: unknown): void } = { dragFile: () => { warnOnceApp( 'dragManager.dragFile', '[Reams] app.dragManager.dragFile() is not implemented. File drag-and-drop is not supported.' ) return null }, onDragStart: () => { warnOnceApp( 'dragManager.onDragStart', '[Reams] app.dragManager.onDragStart() is not implemented. File drag-and-drop is not supported.' ) }, } /** * DOM stub — Periodic Notes reads appContainerEl. Reams-novel API. * @internal Reams-internal API — not part of Obsidian's App. */ readonly reamsDom: { appContainerEl: HTMLElement } /** * The last known user interaction event, to help commands find out what modifier keys are pressed. * @public * @since 0.12.17 */ lastEvent: UserEvent | null = null /** @internal Document manager (set after App construction) */ private _documentManager: DocumentManager | null = null /** @internal Host composition; never a public Vault path. */ reamsStateData?: VaultStateData private readonly _documentSyncCallbacks = new Map>() private readonly _recordEditQueues = new Map>() private readonly _documentSyncGenerations = new Map() private readonly _documentManagerListeners = new Set<() => void>() /** * Navigation callback set by the React layer (AppShell). * Called by workspace.openFile() to open a file in a new tab. * Parameters: (url: string, name: string) => void * @internal */ private _onNavigate: ((url: AutomergeUrl, name: string) => void) | null = null /** @internal LocalStorage namespace for this vault */ private readonly storagePrefix: string /** @internal Device-local UI keys for this edition/account. */ readonly storageScope?: string /** @internal */ constructor(vault: Vault, workspace: Workspace, options?: { storageScope?: string }) { this.vault = vault this.workspace = workspace this.storageScope = options?.storageScope this.storagePrefix = options?.storageScope ? `reams:${options.storageScope}:vault:${vault.getName()}:` : `reams:vault:${vault.getName()}:` // Set back-reference so plugins can access app via workspace and leaf workspace.app = this // Set window.app for plugins that access the global app singleton // (e.g., Calendar's bundled Periodic Notes code accesses window.app) if (typeof window !== 'undefined') { window.app = this } // Initialize managers this.commands = new CommandRegistry() this.hotkeys = new HotkeyManager(this.commands, isMacPlatform(), () => { const view = workspace.activeEditor return view ? { editor: view.editor, view } : null }) this.reamsRibbon = new RibbonActionManager() this.reamsStatusBar = new StatusBarActionManager() this.reamsFormattingActions = new FormattingActionManager() this.reamsToolbarChrome = new ToolbarChromeStore() this.reamsEvents = new ReamsEvents() // DOM stub — the workspace's containerEl serves as appContainerEl this.reamsDom = { appContainerEl: workspace.containerEl, } // FileManager wraps the vault for Obsidian-compatible file operations this.fileManager = createFileManager(vault) // Image resolver maps vault file paths to blob URLs for the embed widget. // Owned by App; disposed in destroy(). this.reamsImageResolver = new ImageResolver(vault) // URL router (#411) — bridges window.location.hash and Workspace. // attach()/detach() are driven from reattach()/destroy() below. this.urlRouter = new UrlRouter(this) // Global keyboard scope this.scope = new Scope() // Keymap manager for scope and modifier key handling this.keymap = new Keymap() // Secret storage for sensitive credentials this.secretStorage = new SecretStorage(options?.storageScope ? `reams:${options.storageScope}:secret:` : undefined) // Render context for value rendering utilities this.renderContext = new RenderContext() // Plugins wrapper — delegates to PluginLoader when available const self = this this.plugins = { getPlugin(id: string): Plugin | null { return self._pluginLoader?.getPlugin(id) ?? null }, enablePlugin(id: string, options?: { userInitiated?: boolean }): Promise { if (!self._pluginLoader) return Promise.resolve() return self._pluginLoader.enablePlugin(id, options) }, disablePlugin(id: string): void { self._pluginLoader?.disablePlugin(id) }, get plugins(): Record { if (!self._pluginLoader) return {} const record: Record = {} for (const id of self._pluginLoader.getPluginIds()) { const plugin = self._pluginLoader.getPlugin(id) if (plugin) { record[id] = plugin } } return record }, /** Set of active plugin IDs. Computed on access from the loader's * live state — not cached, since callers go through loader.enablePlugin() * directly (bypassing app.plugins), so a version-based cache would go stale. * Iteration over ~10-20 plugin IDs is microseconds. */ get enabledPlugins(): Set { if (!self._pluginLoader) return new Set() const ids = new Set() for (const id of self._pluginLoader.getPluginIds()) { if (self._pluginLoader.getPluginState(id) === 'active') { ids.add(id) } } return ids }, on( name: K, callback: (...args: PluginLoaderEvents[K]) => unknown ): EventRef { if (!self._pluginLoader) { // Return a no-op EventRef when no loader is set. The ref is valid // (e is a proper Events instance) but the callback will never fire // since there's no loader to emit events. Cleanup via offref() is safe. return self._noopPluginEvents.on(name as string, callback as (...data: unknown[]) => unknown) } return self._pluginLoader.on(name, callback) }, offref(ref: EventRef): void { // Try both the loader and our no-op instance — only one will match. self._pluginLoader?.offref(ref) self._noopPluginEvents.offref(ref) }, } // Internal plugins API — maps Obsidian ids to Reams built-ins this.internalPlugins = { getPluginById: (id: string): InternalPluginAdapter | null => { return this._getInternalPluginAdapter(id) }, getEnabledPluginById: (id: string): InternalPluginAdapter | null => { const adapter = this._getInternalPluginAdapter(id) if (!adapter || !adapter.enabled) return null return adapter }, } } /** * Document manager for Automerge doc operations. Reams-novel API. * @internal Reams-internal API — not part of Obsidian's App. */ get reamsDocumentManager(): DocumentManager { if (!this._documentManager) { throw new Error('DocumentManager not initialized. Call setDocumentManager() first.') } return this._documentManager } /** @internal Host-internal readiness check for lazily registered views. */ hasDocumentManager(): boolean { return this._documentManager !== null } /** * Set the document manager instance. * Called once during app initialization. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (AppShell) to wire up Automerge doc management. */ setDocumentManager(dm: DocumentManager): void { this._documentManager = dm for (const listener of this._documentManagerListeners) listener() } /** @internal Host-internal readiness subscription. */ subscribeDocumentManagerReady(listener: () => void): () => void { this._documentManagerListeners.add(listener) return () => this._documentManagerListeners.delete(listener) } /** @internal Host-internal document sync wiring. */ registerDocumentSyncCallbacks(documentId: DocumentId, callbacks: DocumentSyncCallbacks): () => void { const participants = this._documentSyncCallbacks.get(documentId) ?? new Set() participants.add(callbacks) this._documentSyncCallbacks.set(documentId, participants) this._documentSyncGenerations.set(documentId, (this._documentSyncGenerations.get(documentId) ?? 0) + 1) const activeRecordEdits = this._recordEditQueues.get(documentId) if (activeRecordEdits) { void (async () => { await this.awaitDocumentRecordEdits(documentId) if (!this._documentSyncCallbacks.get(documentId)?.has(callbacks)) return try { await callbacks.reload() } catch (error) { console.error('[App] newly registered projection could not reload after an in-flight Record edit', error) } })() } return () => { participants.delete(callbacks) if (participants.size === 0) this._documentSyncCallbacks.delete(documentId) this._documentSyncGenerations.set(documentId, (this._documentSyncGenerations.get(documentId) ?? 0) + 1) } } /** @internal Host-internal document sync wiring. */ documentSyncCallbacks(documentId: DocumentId): readonly DocumentSyncCallbacks[] { return [...this._documentSyncCallbacks.get(documentId) ?? []] } /** @internal Perform one bounded flush attempt for a scalar semantic write. */ async flushDocumentSync(documentId: DocumentId, timeoutMs = 5_000): Promise { const participants = this.documentSyncCallbacks(documentId) if (participants.length === 0) return true const results = await Promise.all(participants.map(async callback => { try { return await Promise.race([ callback.flush(), new Promise(resolve => setTimeout(() => resolve(false), timeoutMs)), ]) } catch { return false } })) return results.every(Boolean) } /** @internal Flush every open document before a feature-wide host teardown. */ async flushAllDocumentSync(): Promise { const results = await Promise.all([...this._documentSyncCallbacks.keys()].map(documentId => this.flushDocumentSync(documentId))) return results.every(Boolean) } /** @internal Await every Record command accepted before a feature teardown. */ async awaitRecordEdits(): Promise { while (this._recordEditQueues.size > 0) await Promise.all([...this._recordEditQueues.values()]) } private async awaitDocumentRecordEdits(documentId: DocumentId): Promise { for (;;) { const pending = this._recordEditQueues.get(documentId) if (!pending) return await pending await Promise.resolve() } } /** @internal Atomically flush editors and pause persistence for an external semantic write. */ private async prepareDocumentSync(documentId: DocumentId): Promise<{ readonly callbacks: readonly DocumentSyncCallbacks[]; readonly release: () => void } | null> { for (;;) { const generation = this._documentSyncGenerations.get(documentId) ?? 0 const callbacks = this.documentSyncCallbacks(documentId) const releases: Array<() => void> = [] for (const callback of callbacks) { let release: (() => void) | null try { release = await callback.prepareExternalWrite() } catch { for (const current of releases.reverse()) current() return null } if (!release) { for (const current of releases.reverse()) current() return null } releases.push(release) } if ((this._documentSyncGenerations.get(documentId) ?? 0) !== generation) { for (const current of releases.reverse()) current() continue } let released = false const release = () => { if (released) return released = true for (const release of releases.reverse()) release() } return { callbacks, release } } } /** @internal Execute one authoritative scalar edit against current Automerge state. */ private async runRecordEdit(input: AppRecordEditInput): Promise { return setRecordField({ ...input, enabled: () => input.enabled && isTypesRecordsEnabled() }) } /** @internal Run the complete serialized Record command and projection pipeline. */ submitRecordEdit(input: AppRecordEditInput): Promise { const previous = this._recordEditQueues.get(input.documentId) ?? Promise.resolve() const run = async (): Promise => { const prepared = await this.prepareDocumentSync(input.documentId) if (!prepared) return { status: 'unresolved' } try { const outcome = await this.runRecordEdit(input) if ((outcome.status === 'accepted' || outcome.status === 'unchanged') && outcome.textContribution) { await Promise.all(prepared.callbacks.map(async callback => { if (!this._documentSyncCallbacks.get(input.documentId)?.has(callback)) return try { await callback.project(outcome.textContribution!) } catch (error) { try { await callback.reload() console.error('[App] committed document contribution projection failed; reloaded canonical text', error) } catch (reloadError) { console.error('[App] committed document contribution projection and canonical reload failed', error, reloadError) } } })) } return outcome } finally { prepared.release() } } const result = previous.then(run, run) const settled = result.then(() => undefined, () => undefined) this._recordEditQueues.set(input.documentId, settled) void settled.finally(() => { if (this._recordEditQueues.get(input.documentId) === settled) this._recordEditQueues.delete(input.documentId) }) return result } /** @internal Project a committed contribution without changing commit outcome. */ async projectDocumentContribution(contribution: TextContribution): Promise { await Promise.all(this.documentSyncCallbacks(contribution.documentId).map(async callback => { if (!this._documentSyncCallbacks.get(contribution.documentId)?.has(callback)) return try { await callback.project(contribution) } catch (error) { console.error('[App] committed document contribution projection failed', error) } })) } /** * Set the navigation callback for opening files in tabs. * Called by the React layer (AppShell) to wire up the plugin system * to the tab/layout system. Pass null to clear. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (AppShell) to bridge the plugin API to the tab system. */ setNavigateHandler(handler: ((url: AutomergeUrl, name: string) => void) | null): void { this._onNavigate = handler } /** * Navigate to a file by opening it in a tab. * Used by workspace.openFile() to bridge the plugin API to Reams' tab system. * Returns false if navigation is not set up. * @internal Reams-internal API — not part of Obsidian's App. */ navigateToFile(path: string, name: string): boolean { const url = this.vault.getUrlForPath(path) if (!url || !this._onNavigate) return false this._onNavigate(url, name) return true } /** * Set the metadata cache instance. * Called once during SearchProvider initialization. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (SearchProvider) to wire up metadata indexing. */ setMetadataCache(mc: MetadataCache): void { this.metadataCache = mc setFileManagerMetadataCache(this.vault, mc) } /** * Search service for full-text indexed search across the vault. * Reams-novel API. * * Throws if accessed before SearchProvider has called `setSearchService`. * Built-in plugins that depend on search (wikilink autocomplete) must * call `hasSearchService()` first when running in environments where * SearchService may not be available (e.g. unit tests). * @internal Reams-internal API — not part of Obsidian's App. */ get reamsSearch(): SearchService { if (!this._searchService) { throw new Error('SearchService not initialized. Call setSearchService() first.') } return this._searchService } /** * Returns true if the SearchService has been wired up. Plugins that can * fall back gracefully (wikilink autocomplete falls back to a path scan) * should gate `app.reamsSearch` access on this check rather than catching the * thrown error. * @internal Reams-internal API — not part of Obsidian's App. */ hasSearchService(): boolean { return this._searchService !== null } /** * Set the search service instance. * Called once during SearchProvider initialization. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (SearchProvider) to wire up full-text search. */ setSearchService(s: SearchService): void { this._searchService = s } /** @internal Host-only shared derived projection service. */ get reamsBackgroundIndexer(): BackgroundIndexer { if (!this._backgroundIndexer) throw new Error('BackgroundIndexer not initialized. Call setBackgroundIndexer() first.') return this._backgroundIndexer } /** @internal Whether the host has wired the shared derived service. */ hasBackgroundIndexer(): boolean { return this._backgroundIndexer !== null } /** @internal Host-only service wiring. */ setBackgroundIndexer(indexer: BackgroundIndexer | null): void { this._backgroundIndexer = indexer } /** @internal Clears only the service instance this host bridge installed. */ clearBackgroundIndexer(indexer: BackgroundIndexer): void { if (this._backgroundIndexer === indexer) this._backgroundIndexer = null } /** @internal Query-ready typed-node catalog owned by SearchProvider. */ get reamsTypedNodes(): TypedNodeCatalog { if (!this._typedNodeCatalog) { throw new Error('TypedNodeCatalog not initialized. Call setTypedNodeCatalog() first.') } return this._typedNodeCatalog } /** @internal Whether the host has wired the typed-node catalog. */ hasTypedNodeCatalog(): boolean { return this._typedNodeCatalog !== null } /** @internal Subscribe to host wiring of the query-facing typed catalog. */ subscribeTypedNodeCatalog(listener: () => void): () => void { this._typedNodeCatalogListeners.add(listener) return () => this._typedNodeCatalogListeners.delete(listener) } /** @internal Host-only service wiring. */ setTypedNodeCatalog(catalog: TypedNodeCatalog): void { this._typedNodeCatalog = catalog for (const listener of [...this._typedNodeCatalogListeners]) listener() } /** @internal Notify host-derived indexes after a canonical document save. */ notifyDocumentContentChanged(file: TFile, contribution?: TextContribution): boolean { if (!this._onDocumentContentChanged) return false this._onDocumentContentChanged(file, contribution) return true } /** @internal SearchProvider-owned bridge from editor saves to indexing. */ setDocumentContentChangedHandler(handler: ((file: TFile, contribution?: TextContribution) => void) | null): void { this._onDocumentContentChanged = handler } /** @internal Clears only the host callback installed by this bridge. */ clearDocumentContentChangedHandler(handler: ((file: TFile, contribution?: TextContribution) => void) | null): void { if (this._onDocumentContentChanged === handler) this._onDocumentContentChanged = null } /** * Register a construct spec on behalf of a plugin. * Validates the spec and delegates to the shared ConstructRegistry. * * @internal Reams-internal API — not part of Obsidian's App. */ registerConstruct(pluginId: string, spec: ConstructSpec): void { const registry = getCoreSemanticRegistry() registry.register(`plugin:${pluginId}`, spec) } /** * Unregister all construct specs owned by a plugin. * Called during plugin unload. Existing marks in documents are preserved * (the reconciler treats them as opaque until the plugin reloads). * * @internal Reams-internal API — not part of Obsidian's App. */ unregisterConstructs(pluginId: string): void { const registry = getCoreSemanticRegistry() registry.unregisterPluginSpecs(pluginId) } /** * Set the plugin loader instance. * Called once during app initialization. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (AppShell) to wire up plugin management. */ setPluginLoader(loader: PluginLoaderLike): void { this._pluginLoader = loader } /** * Get or create an internal plugin adapter for the given Obsidian id. * Returns null if the id is not in the mapping or the Reams plugin isn't loaded. * Adapters are cached per id for stable identity across calls. * @internal */ private _getInternalPluginAdapter(obsidianId: string): InternalPluginAdapter | null { // Check if this Obsidian id maps to a Reams plugin const reamsId = OBSIDIAN_TO_REAMS_PLUGIN_ID[obsidianId] if (!reamsId) { // Unknown id - return null (matches Obsidian behavior) return null } // Check if the Reams plugin exists const reamsPlugin = this._pluginLoader?.getPlugin(reamsId) if (!reamsPlugin) { // Plugin not loaded - return null // Clear any stale cached adapter this._internalPluginAdapterCache.delete(obsidianId) return null } const isActive = this._pluginLoader?.getPluginState(reamsId) === 'active' // Check if we have a cached adapter const cached = this._internalPluginAdapterCache.get(obsidianId) if (cached) { // Update the enabled state dynamically (plugin may have been toggled) cached.enabled = !!isActive // Update instance.options to reflect current settings if (cached.enabled) { cached.instance = { options: this._getInternalPluginOptions(obsidianId, reamsPlugin), } } else { cached.instance = null } return cached } // Create a new adapter const enabled = !!isActive const adapter: InternalPluginAdapter = { enabled, instance: enabled ? { options: this._getInternalPluginOptions(obsidianId, reamsPlugin) } : null, enable: () => { this.plugins.enablePlugin(reamsId).catch(() => { // Silently ignore enable errors (matches Obsidian behavior) }) }, disable: (_save?: boolean) => { this.plugins.disablePlugin(reamsId) }, } this._internalPluginAdapterCache.set(obsidianId, adapter) return adapter } /** * Convert Reams plugin settings to Obsidian-compatible internal plugin options. * Each Obsidian internal plugin expects a specific shape; we map Reams settings * to that shape here. * @internal */ private _getInternalPluginOptions(obsidianId: string, plugin: Plugin): Record { // Access the plugin's settings if available const settings = (plugin as unknown as { settings?: Record }).settings switch (obsidianId) { case 'daily-notes': { // Periodic Notes expects: { folder, format, template } // Reams DailyNotesPlugin has: { folder, dateFormat, templatePath } return { folder: settings?.folder ?? '', format: settings?.dateFormat ?? 'YYYY-MM-DD', template: settings?.templatePath ?? '', } } case 'templates': { // Templates plugin settings: { folder, dateFormat, timeFormat } // Reams TemplatesPlugin has similar shape return { folder: settings?.folder ?? '', dateFormat: settings?.dateFormat ?? 'YYYY-MM-DD', timeFormat: settings?.timeFormat ?? 'HH:mm', } } case 'file-explorer': case 'global-search': case 'switcher': default: // Return empty options for plugins without settings or unknown mappings return {} } } /** * Check if dark mode is enabled. * @public * @since 1.10.0 */ isDarkMode(): boolean { return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true } /** * Retrieve value from `localStorage` for this vault. * @param key * @public * @since 1.8.7 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any loadLocalStorage(key: string): any | null { try { const value = localStorage.getItem(this.storagePrefix + key) if (value === null) return null return JSON.parse(value) } catch { return null } } /** * Save vault-specific value to `localStorage`. If data is `null`, the entry will be cleared. * @param key * @param data value being saved to localStorage. Must be serializable. * @public * @since 1.8.7 */ saveLocalStorage(key: string, data: unknown | null): void { try { if (data === null) { localStorage.removeItem(this.storagePrefix + key) } else { localStorage.setItem(this.storagePrefix + key, JSON.stringify(data)) } } catch { // localStorage may be unavailable } } /** * Clean up resources. * * @internal Host-internal lifecycle method. Called by the Reams host * (AppShell) on unmount. Plugins must not call this method — plugin * cleanup should happen in Plugin.onunload(). */ destroy(): void { // Detach (not destroy) the hotkey listener. The App instance survives // React StrictMode unmount/remount via useMemo, so bindings must survive. // reattach() is called when the component re-mounts. this.hotkeys.detach() this.urlRouter.detach() _destroyScope(this.scope) // NOTE: reamsRibbon, reamsFormattingActions, reamsToolbarChrome, reamsStatusBar, and reamsImageResolver are // intentionally NOT torn down here. These all hold state that must // survive React StrictMode unmount/remount cycles (the App instance // is preserved across them via useMemo, but `destroy()` runs on the // unmount). For reamsImageResolver in particular: calling dispose() here // would revoke blob URLs AND unsubscribe from Vault modify/delete/ // rename events, and reattach() does NOT re-subscribe — the cache // would silently stop invalidating for the rest of the dev session. // Resolver state is reclaimed when the page unloads (browser revokes // blob URLs and tears down event listeners automatically). Plugin- // owned resources are cleaned up individually via removePluginHotkeys // ()/removePluginIcons()/removePluginItems()/removePluginActions() // when plugins unload. } /** * Re-initialize after a React StrictMode remount. * Call this when the component re-mounts to restore detached listeners. * * @internal Host-internal lifecycle method. Called by the Reams host * (AppShell) to restore detached listeners after StrictMode remount. * Plugins must not call this method. */ reattach(): void { this.hotkeys.reattach() this.urlRouter.attach() } }