Skip to main content

Overview

Sync Folds solves a fundamental limitation in Obsidian: fold states are stored in localStorage, which doesn’t sync across devices. This plugin intercepts localStorage operations and persists fold states to a file that syncs with your vault.

The Core Mechanism

The plugin operates through three key phases: interception, persistence, and synchronization.
1

localStorage Interception

When the plugin loads, it intercepts Obsidian’s localStorage operations by overriding the native setItem and removeItem methods. This allows the plugin to detect every fold state change.
interceptLocalStorage() {
    const app = this.app
    const appId = app.appId
    const foldPrefix = `${appId}-note-fold-`

    this.originalSetItem = localStorage.setItem.bind(localStorage)
    this.originalRemoveItem = localStorage.removeItem.bind(localStorage)

    localStorage.setItem = (key: string, value: string) => {
        this.originalSetItem(key, value)

        if (key.startsWith(foldPrefix)) {
            const filePath = key.replace(foldPrefix, '')
            this.debouncedSyncFile(filePath, value)
        }
    }

    localStorage.removeItem = (key: string) => {
        this.originalRemoveItem(key)

        if (key.startsWith(foldPrefix)) {
            const filePath = key.replace(foldPrefix, '')
            this.debouncedSyncFile(filePath, null)
        }
    }
}
The plugin preserves the original methods and calls them first to maintain Obsidian’s normal behavior.
2

Debounced Persistence

When a fold state changes, the plugin doesn’t save immediately. Instead, it uses a 150ms debounce to batch rapid changes and reduce file I/O.
debouncedSyncFile(filePath: string, value: string | null) {
    if (this.debounceTimer !== null) {
        window.clearTimeout(this.debounceTimer)
    }

    this.debounceTimer = window.setTimeout(async () => {
        await this.upsertFoldStateForFile(filePath, value)
        this.debounceTimer = null
    }, 150)
}
This means if you fold and unfold multiple headings quickly, only the final state is written to disk.
3

File-Based Synchronization

Fold states are persisted to .obsidian/plugins/sync-folds/data.json. Since this file is part of your vault, any file sync solution (Obsidian Sync, iCloud, Git, etc.) will automatically sync it across devices.The plugin detects when this file changes externally and updates localStorage accordingly.

Data Flow Diagram

Initial State Handling

When the plugin first loads, it intelligently decides whether to import or export:
const folds = this.getFoldsObject()
const hasSavedFolds = Object.keys(folds).length > 0

if (hasSavedFolds) {
    log('Existing folds found in settings: importing to localStorage')
    this.importFoldsToStorage()
} else {
    log('No folds in settings: exporting from localStorage')
    await this.exportFoldsToFile()
}
  • If data.json contains fold states → import them to localStorage
  • If data.json is empty → export current localStorage folds to the file

External Settings Change Detection

The plugin monitors for external changes to detect when fold states are synced from another device:
public async onExternalSettingsChange(): Promise<void> {
    const previousFolds = { ...this.cachedFolds }
    await this.loadSettings()
    const currentFolds = this.getFoldsObject()

    // Remove folds that were deleted
    for (const filePath of Object.keys(previousFolds)) {
        if (!currentFolds[filePath]) {
            const key = `${appId}-note-fold-${filePath}`
            this.originalRemoveItem.call(localStorage, key)
        }
    }

    // Upsert folds that changed
    for (const [filePath, foldData] of Object.entries(currentFolds)) {
        if (JSON.stringify(previousFolds[filePath]) !== JSON.stringify(foldData)) {
            const key = `${appId}-note-fold-${filePath}`
            const value = JSON.stringify(foldData)
            this.originalSetItem.call(localStorage, key, value)
        }
    }

    this.cachedFolds = currentFolds
}
The plugin maintains a cachedFolds object to track the last known state. This allows it to efficiently detect what changed when the file is updated externally, avoiding unnecessary localStorage updates.

Fold State Data Structure

Fold states are stored as JSON objects mapping file paths to fold information:
export interface Fold {
    from: number  // Starting line number
    to: number    // Ending line number
}

export interface FoldedProperties {
    folds: Fold[]  // Array of folded regions
    lines: number  // Total line count
}

export interface FoldStateData {
    [filePath: string]: FoldedProperties
}
Example data.json content:
{
  "enableSync": true,
  "folds": "{\"path/to/note.md\":{\"folds\":[{\"from\":5,\"to\":12}],\"lines\":50}}"
}
The folds field is stored as a stringified JSON object rather than a nested object. This is because Obsidian’s plugin data API serializes and deserializes the entire settings object.

Performance Considerations

Debouncing

The 150ms debounce prevents excessive writes when you’re rapidly folding/unfolding content. Only the final state is persisted.

Cached State

The plugin caches the current fold state in memory to avoid re-reading the file on every localStorage operation.

Selective Updates

When external changes are detected, only modified fold states are updated in localStorage, not the entire state tree.

Original Method Binding

The original localStorage methods are bound and preserved, ensuring native performance for non-fold localStorage operations.

Enable/Disable Sync

The plugin respects the enableSync setting from settings.ts:
export interface SyncFoldSettings {
    enableSync: boolean
    folds: string
}

export const DEFAULT_SETTINGS: SyncFoldSettings = {
    enableSync: true,
    folds: '{}'
}
When enableSync is false, the plugin skips:
  • localStorage interception
  • Debounced syncing
  • File exports
  • External change detection
If you disable sync after using the plugin, existing fold states in localStorage will remain, but new changes won’t be persisted to data.json.

Build docs developers (and LLMs) love