Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134// src/plugin/app.tsimport 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<boolean> readonly prepareExternalWrite: () => Promise<(() => void) | null> readonly project: (contribution: TextContribution) => Promise<void> readonly reload: () => Promise<void>}
/** @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<string>()
/** 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<string, string> = { '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<string, unknown> } | 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<void> disablePlugin(id: string): void // Events (Reams-internal on/offref pattern for host React components) on<K extends keyof PluginLoaderEvents>( 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<string, InternalPluginAdapter>()
/** * 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<void> disablePlugin(id: string): void readonly plugins: Record<string, Plugin> /** Set of active plugin IDs. Obsidian-compatible — plugins like Recent Files * check `app.plugins.enabledPlugins.has(id)` for inter-plugin integration. */ readonly enabledPlugins: Set<string> /** * 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<K extends keyof PluginLoaderEvents>( 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<DocumentId, Set<DocumentSyncCallbacks>>() private readonly _recordEditQueues = new Map<DocumentId, Promise<void>>() private readonly _documentSyncGenerations = new Map<DocumentId, number>() 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<void> { if (!self._pluginLoader) return Promise.resolve() return self._pluginLoader.enablePlugin(id, options) }, disablePlugin(id: string): void { self._pluginLoader?.disablePlugin(id) }, get plugins(): Record<string, Plugin> { if (!self._pluginLoader) return {} const record: Record<string, Plugin> = {} 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<string> { if (!self._pluginLoader) return new Set() const ids = new Set<string>() for (const id of self._pluginLoader.getPluginIds()) { if (self._pluginLoader.getPluginState(id) === 'active') { ids.add(id) } } return ids }, on<K extends keyof PluginLoaderEvents>( 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<DocumentSyncCallbacks>() 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<boolean> { 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<boolean>(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<boolean> { 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<void> { while (this._recordEditQueues.size > 0) await Promise.all([...this._recordEditQueues.values()]) }
private async awaitDocumentRecordEdits(documentId: DocumentId): Promise<void> { 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<RecordWriteOutcome> { return setRecordField({ ...input, enabled: () => input.enabled && isTypesRecordsEnabled() }) }
/** @internal Run the complete serialized Record command and projection pipeline. */ submitRecordEdit(input: AppRecordEditInput): Promise<RecordWriteOutcome> { const previous = this._recordEditQueues.get(input.documentId) ?? Promise.resolve() const run = async (): Promise<RecordWriteOutcome> => { 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<void> { 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<string, unknown> { // Access the plugin's settings if available const settings = (plugin as unknown as { settings?: Record<string, unknown> }).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() }}