Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

App State and Workspace Model

App — Root State Owner

The App class (packages/api/src/lib/context.svelte.ts) is the root owner for all application state. It constructs and holds every runtime service.

State Ownership Map

OwnerResponsibilities
AppWorkspace, vault, configuration, settings, command manager, metadata cache, plugin manager, localization, editor registries, embed registry, secret storage, telemetry service, session-scoped Safe Mode policy
WorkspaceSplit-tree layout, active leaf, transient root-tab focus mode, view type registry, editor-view metadata registry, association routing, extension-to-type fallback mapping, sidebar state
WorkspaceSplitNested horizontal or vertical layout branches with proportional sizing
WorkspaceTabsGrouped leaves or sidebar groups sharing a tab strip, current tab index, stacked mode
WorkspaceSidebarGroupSidebar-only view containers with ordered child leaves plus hidden, collapsed, and resized panel state
WorkspaceLeafSingle view host, navigation history, container element
View subclassesRendering and behavior for a specific content type

Reactivity Model

Files ending in .svelte.ts use Svelte 5 runes for reactive state:

  • $state for mutable reactive properties (e.g., WorkspaceTabs.currentTab, Menu.open, CommandManager.open).
  • $derived for computed values.
  • $effect for side effects on state changes.

This means the workspace model is reactive: UI components automatically re-render when model properties change.

Open-File Flow

When app.openFile(file) runs:

  1. Tries to reuse an already-open matching leaf in the focused command host, then falls back to the active main-area leaf.
  2. If the active leaf is in a sidebar, falls back to the selected root-split leaf instead of replacing the sidebar view.
  3. If no main-area leaf exists, creates a new WorkspaceLeaf in the root split.
  4. Determines view type from workspace.editorAssociations, registered editor-view filename patterns, or the registered extension fallback.
  5. Looks up the view constructor from the registry.
  6. If no registered constructor exists, checks verified official registry contribution summaries for an on-demand install prompt.
  7. Creates and mounts the view in the leaf.
  8. The leaf’s setViewState() triggers visual update.

When a leaf replaces one TextFileView with another, the previous editor instance is destroyed after the old view unloads. This keeps repeated file switches from accumulating detached CodeMirror editors in memory and turning later navigations into progressively slower reloads.

File navigation is a mutation of the workspace model first, then a visual update. Default file navigation APIs such as app.openFile(file), workspace.openLinkText(...), and workspace.getLeaf(false) target the main root split unless a floating or popout command host currently owns focus. When the focused host already has that file open, app.openFile(file) reselects and reveals the existing leaf instead of reopening the file in another pane. Sidebar APIs such as ensureSideLeaf(), getLeftLeaf(), and getRightLeaf() remain the explicit way to create or reuse sidebar leaves, so clicking a file link from search, tags, backlinks, or another sidebar surface does not replace the sidebar panel even when no main tab is currently selected.

The current implementation also traces this path through app.telemetry. app.openFile(), WorkspaceLeaf.setViewState(), WorkspaceLeaf.openFile(), TextFileView.onLoadFile(), and selected editor reconfiguration steps emit nested spans so slow opens can be attributed to state restore, file I/O, editor state creation, or view mounting. Slow spans capture a lightweight workspace snapshot including active view type, sidebar visibility, leaf counts, enabled plugin count, and a DOM node count bucket.

The browser telemetry service also publishes a small reactive diagnostics snapshot on app.telemetry.diagnostics. It keeps bounded ring buffers for recent slow spans and recent measurements (web-vitals plus longtask) so the diagnostics core plugin can expose a local workspace view without depending on an OTLP backend or browser devtools.

App exposes emulateMobile(enable?) for devtools use: it persists workspace.mobile.mode as always when enable is true or omitted, or as never when enable is false, then reloads the renderer when window is available.

App now also carries a session-scoped safeMode record. It captures temporary startup recovery policy such as disabling community plugins, skipping saved layout restore, or blocking notebook execution for the next reload, plus the last preserved startup failure summary when the user entered Safe Mode from a recovery surface. The workspace shell mirrors this state into context keys so commands and plugins can cheaply gate behavior like notebook execution without consulting renderer-only state.

Workspace.activeLeaf is treated as an in-tree selection, not just a cached pointer. If a close, unload, or layout restore leaves the cached leaf detached from the current split tree, the workspace falls back to the selected leaf from the live tab tree instead of exposing the stale reference to callers. Workspace.activeRootLeaf applies the same validation while only considering the root split, which keeps sidebar-originated default file navigation out of the sidebars without changing focus tracking for sidebar views. When the active leaf belongs to an in-app floating pane or host-backed popout, default file navigation stays in that same floating window so rendered-note link clicks do not jump back to the main root split.

The workspace model also carries a presentation-only displayMode state with values desktop and mobile. This is not a second layout tree and it is not serialized into workspace.json; it exists so the renderer shell and plugins can react to whether the current shell is presenting the shared layout as a desktop or mobile workspace. setDisplayMode() emits a typed display-mode-change event when the mode actually changes, while isMobileMode is the convenience getter used by mobile-aware shell logic. Plugins should treat this as a presentation hint rather than a layout fork: mobile mode can hide visible split chrome, show only one active leaf at a time, and route sidebar-owned leaves through alternative navigation surfaces without changing the underlying workspace tree.

Mobile and adaptive shells use public leaf-control helpers instead of reaching into private tab-selection internals. getOpenLeafEntries() walks the main split, left and right sidebars, and floating or popout windows to expose a flat list of open leaves with region metadata, active state, selected-in-parent state, file path where applicable, and parent tab, group, or window identity. activateLeaf() selects the correct parent tab or sidebar group, unhides a grouped sidebar leaf when needed, and brings floating or popout windows to the front without requiring shell code to understand the workspace tree. The companion closeLeafAndSelectFallback() helper preserves normal close behavior while choosing a sensible remaining leaf when an adaptive shell closes the active leaf.

The workspace also exposes the owning Document for the focused command host. API-owned modal wrappers use that document’s body as their dialog portal target, so popup-triggered dialogs render inside the popup window instead of falling back to the parent renderer document.

The workspace model also exposes getVisibleHintTargets() for bundled keyboard navigation overlays. It scans workspace.containerEl for visible enabled nodes marked with data-hint-target, normalizes the associated data-hint-* metadata into runtime records, and returns only targets whose DOM rects are present in the current shell. This keeps chrome-local opt-in metadata in the workspace renderer while leaving hint-label generation and activation behavior to the owning plugin.

Layout Model

The layout tree is recursive:

WorkspaceSplit (vertical | horizontal)
├── WorkspaceSplit (nested)
│   └── ...
└── WorkspaceTabs
    ├── WorkspaceLeaf (view: MarkdownView)
    ├── WorkspaceLeaf (view: EmptyView)
    └── WorkspaceSidebarGroup
        ├── WorkspaceLeaf (view: SearchView)
        └── WorkspaceLeaf (view: GraphView)

Workspace components in packages/workspace render this tree; they do not own an independent layout model.

Layout Serialization

workspace.getLayout() serializes the full tree to JSON:

  • Split types, children, sizes.
  • Tab group state (current tab, stacked mode).
  • Sidebar group state (id, name, icon, child leaves, hidden child IDs, collapsed child panels, and resized child panel proportions).
  • Leaf view states (view type, file path, scroll position, etc.).

workspace.loadLayout(json) reconstructs the tree from JSON. workspace.changeLayout(json) uses the same object-restore path for already loaded layout snapshots, including required view-plugin activation, active leaf restoration, layout-ready notification, and live layout persistence after a successful replacement. Flat sidebar layouts without WorkspaceSidebarGroup entries remain valid, and grouped sidebar layouts round-trip without changing the underlying child leaves. Grouped sidebar panel sizes are optional, so older layouts without panelSizes keep loading with equal panel proportions. requestSaveLayout() debounces persistence to the vault and is queued when restorable workspace state changes, including layout mutations, grouped sidebar panel resizing, sidebar visibility changes, and leaf view or file opens.

Presentation-only workspace state such as displayMode is intentionally kept out of this serialized layout contract, so switching between desktop and mobile shells does not rewrite split sizes, sidebar widths, or other persisted layout state merely because the renderer chose a different presentation.

The workspace model also keeps a transient focusMode record for promoted root tab groups. enterFocusMode(leaf) only accepts leaves that still belong to the root split, exitFocusMode() clears the active promotion without mutating the split tree, and restore paths such as restoreLayoutJson() or focused-leaf teardown clear the transient state before rehydrating layout. Focus mode is intentionally not serialized into .obsidian/workspace.json, so reload always returns to the normal shell layout.

Committed workspace drag/drop mutations now route through shared model helpers instead of direct renderer-side split-tree edits. moveWorkspaceChildToTabIndex() owns top-tab and top-level sidebar reorder, dropWorkspaceItemOnTabs() owns center drops plus edge split drops for leaves and files, and moveLeafToSidebarGroupIndex() / moveLeafToSidebarGroup() own grouped-sidebar panel reorders and group ingestion. Those helpers fire cancelable layout-will-drop events before mutating layout, emit layout-did-drop after success, and finish with a single layout-change marked with source: "drag-drop". When a drop operation needs to clone or open a leaf as part of the mutation, the workspace suppresses transient nested save requests so listeners observe only the final committed drag/drop change.

Command and Keyboard System

Command Registration

Commands are registered with app.commands.registerCommand(cmd):

interface Command {
  id: string; // "plugin:command-id"
  name: string; // "Plugin: Command Name"
  icon?: string;
  repeatable?: boolean;
  callback?: () => any; // Global
  checkCallback?: (checking) => boolean | void;
  editorCallback?: (editor, ctx) => any;
  editorCheckCallback?: (checking, editor, ctx) => boolean | void;
  hotkeys?: Hotkey[]; // Default keybindings
}

Commands with editorCallback are stored separately in editorCommands for editor-context execution.

Keyboard Routing

  1. App.svelte captures keydown events at the document level.
  2. Records app.lastEvent.
  3. Gives any additional overlay scopes first chance to handle the event.
  4. When transient workspace focus mode is active, handles bare Escape at the shell boundary before leaf scopes so focused editors cannot swallow the exit action.
  5. Forwards remaining events to the active leaf’s Scope.
  6. Scope.handleEvent(evt) checks key bindings, calls handlers. Return false to preventDefault.
  7. Falls back to command lookup via commandsFor(hotkey) using effective hotkeys from defaults or .obsidian/hotkeys.json overrides.
  8. Executes the first available matching command through executeCommand() so when, checkCallback, and editor-scoped command rules still apply.

Keymap

Keymap manages a stack of Scope objects. pushScope(scope) activates, popScope(scope) deactivates. Events process scopes in reverse (LIFO) order.

Scope.register() also supports wildcard capture for modal overlays: key = null matches any non-modifier key at the requested modifier specificity, and modifiers = null matches regardless of modifiers. This is used by the bundled fmode plugin to capture typed hint characters without registering one binding per letter.

Modifier handling:

  • "Mod" → Meta on Mac, Ctrl on Windows/Linux.
  • "Ctrl" → Ctrl always.
  • "Meta" → Meta (Cmd on Mac, Win on others).
  • Keymap.isModifier(evt, mod) / Keymap.isModEvent(evt) for platform-aware checks.

Left and right docks are represented as sidebar state attached to the workspace model. The Workspace exposes leftSplit and rightSplit as WorkspaceSplit instances. The Svelte shell’s Sidebar.Provider and SidebarState consume these to size and render the visible docks.

WorkspaceSidedock wraps each split with show/hide semantics. Visibility is toggled via commands (app:toggle-left-sidebar, app:toggle-right-sidebar).

Those sidebar and leaf counts now also feed the slow-span snapshot path so hot-page regressions can be correlated with current shell state rather than only with the triggering interaction.

When a sidedock leaf is split, the workspace inserts a nested horizontal split inside the existing sidedock branch so the original tab group and the new leaf stay in the sidebar tree instead of replacing each other.

Sidebar views may also be grouped. Workspace.registerSidebarView() stores default placement metadata for a view type, and Workspace.ensureSideLeaf() accepts explicit placement options for side, group identity, group display metadata, and initial hidden state. Groups are model objects, not plugin-owned composite views, so traversal, active-leaf restore, getLeavesOfType(), and layout persistence continue to operate on the real child leaves. The workspace model also exposes helpers to find/create groups and convert sidebar leaves to a group or a group back to normal sidebar tabs.

Hover Preview and Popouts

The workspace keeps hover-link source registrations in-memory so plugins can register them during onload() and rely on component cleanup to remove them on unload.

openPopoutLeaf() and moveLeafToPopout() now request a host-backed popout window instead of reusing the in-app floating overlay path. When the current host registers popout support, the workspace creates a separate Window/Document, mounts the selected WorkspaceWindow tree there, mirrors theme and plugin styles into that document, and removes the popout from layout state when the secondary window closes. When the host does not support popouts, these APIs raise an explicit unsupported error and leave the original leaf attached.

In-app floating panes and host-backed popouts share the WorkspaceWindow model. moveWorkspaceChildToFloating() moves leaves, sidebar tabs, sidebar groups, or grouped sidebar panel leaves into an in-app floating pane, while moveWorkspaceChildToPopout() moves the same item types through the host popout path when supportsPopoutWindows() is true. Floating panes also carry display state: normal, collapsed, minimized, and maximized. Only collapsed and minimized persist in workspace layout JSON; maximized is a transient renderer state that restores as normal after reload. Workspace.getLeaf() returns the active floating or popout leaf for default navigation before falling back to activeRootLeaf, preserving local navigation inside secondary windows while keeping sidebars from becoming the default file target.

The internal moveWorkspaceChildToWindow() helper builds and commits the destination WorkspaceWindow tree to the floating container before detaching the item from its source. This ordering ensures the moved item always has a reachable parent: if the host popout open fails, the item was never detached and remains in the source tree.

Layout Normalization

Before the workspace model is hydrated from persisted JSON, the raw input is passed through normalizeWorkspaceJson(). This pure function repairs common corruption that can accumulate in long-lived .obsidian/workspace.json files:

  • Sizes arrays whose length does not match their sibling children array, or that contain non-finite values, are repaired by padding or trimming to match and replacing invalid values with an equal-weight default.
  • currentTab indices that are out of range or non-integer are clamped to 0.
  • Empty floating windows (no leaf content) are dropped before the model is hydrated.
  • Popout-mode windows cannot be restored across sessions and are also dropped.
  • Stale sidebar group metadatahiddenLeafIds, collapsed, and panelSizes entries that refer to leaf ids no longer present in the group’s children — are stripped.
  • Unknown or malformed child node types are silently dropped; valid siblings are kept.
  • Missing required fields are filled with safe defaults (e.g. generated ids, "16rem" sidedock width, "vertical" split direction).

The normalizer is applied on every call to restoreLayoutJson(), which is the shared entry point for both loadLayout() (file-based restore on boot) and changeLayout() (programmatic restore from an arbitrary object).

Configuration State

See Configuration and Settings for the full schema-driven configuration system.