Skip to content

chore: split SettingsManager into focused stores (1,470 lines) #240

@dep

Description

@dep

Context

Follow-up to #237 (AppState god-object refactor). SettingsManager.swift is 1,470 lines and follows the same god-object anti-pattern as AppState: 32 @Published properties across 8 distinct domains, all funneled through a single class with debounced YAML/JSON persistence. Every setting mutation triggers a didSet → debounced save, and every view that reads any setting observes the entire manager.

Goal

Decompose SettingsManager into domain-scoped stores following the same pattern as #237:

  1. Each domain (appearance, editor display, startup, sidebar panes, filtering, git/GitHub, sync, vault paths) is its own ObservableObject.
  2. Persistence is a separate concern owned by a SettingsPersistence service — not mixed into the state classes.
  3. Views observe only the store(s) they need — a font change no longer invalidates views that only read pinned items.
  4. Sidebar pane configuration (~30% of the file, 212 lines of type definitions alone) gets its own module.

Non-goals: changing the on-disk YAML/JSON schema, changing default values, or breaking existing settings files.

Current structure (evidence)

Nested types inside SettingsManager (lines 220–1470)

  • Config (498–558) — legacy JSON all-in-one
  • VaultConfig (561–666) — vault-specific YAML (.synapse/settings.yml)
  • GlobalConfig (669–716) — machine-local YAML (GitHub PAT, vault paths)
  • SaveSnapshot (1169–1392) — value-type snapshot for background I/O

32 @Published properties by domain

Appearance (5) — lines 318–343

  • activeThemeName, customThemes, folderAppearances, activeTheme (computed), allThemes (computed)

Editor display (5) — lines 283–303

  • editorBodyFontFamily, editorMonospaceFontFamily, editorFontSize, editorLineHeight, defaultEditMode, hideMarkdownWhileEditing

Startup / launch (7) — lines 224–250

  • onBootCommand, templatesDirectory, dailyNotesEnabled, dailyNotesFolder, dailyNotesTemplate, launchBehavior, launchSpecificNotePath

File filtering (4) — lines 227–232, 314

  • fileExtensionFilter, hiddenFileFolderFilter, respectGitignore, fileTreeMode

Sidebar panes (4) — lines 258–272

  • sidebars, sidebarPaneHeights, collapsedPanes, collapsedSidebarIDs

Pinned items (1) — line 280

  • pinnedItems

Vault paths (2) — lines 306–312

  • vaultPaths, lastNoteFolderPerVault

Sync (2) — lines 251–256

  • autoSave, autoPush

Git / GitHub (1) — line 274

  • githubPAT

Browser (1) — line 289

  • browserStartupURL

Top-level types (212 lines of sidebar type machinery before the class even starts)

  • SidebarPane enum (5–27)
  • SidebarNotePane struct (29–50)
  • SidebarPaneItem enum + custom Codable (52–135)
  • Array<SidebarPaneItem> extension (137–150)
  • SidebarPosition enum (153–156)
  • LaunchBehavior enum (159–187)
  • Sidebar struct (190–200)
  • FixedSidebar enum (204–217)

Persistence

  • YAML + legacy JSON fallback
  • save() at 1132–1151 — snapshots on main, writes on utility queue with 0.5s debounce
  • flush() at 1153–1155 — sync for tests
  • flushDebouncedSaveBeforeReloadIfNeeded() at 1160–1166
  • Three separate write paths: writeLegacy (1274–1340), writeVault (1342–1391), writeGlobalOnly (1256–1272)
  • Three separate load paths: loadConfig, loadVaultConfig, loadGlobalConfig (1394–1430)

Migration / apply methods

  • applyLegacyConfig, applyVaultConfig, applyGlobalConfig, applyNoVaultDefaults — lines 842–1026. Each 50+ lines of explicit field assignment with overlapping logic.

Sidebar mutation methods (357–444)

  • assignPane, movePane, movePaneItem, insertNotePane, removePane, removePaneItem, isSidebarCollapsed, toggleSidebarCollapsed. movePaneItem has subtle index-adjustment logic.

File filtering methods (1047–1128)

  • parsedExtensions, parsedHiddenPatterns, shouldHideItem, shouldShowFile, isHiddenByAncestor

Vault path discovery (1437–1467)

  • discoverVaultPath() iterates vault candidates

Target architecture

macOS/Synapse/Settings/
 ├── SettingsCoordinator.swift           (~150 — owns all stores, wires persistence)
 │
 ├── Stores/
 │    ├── AppearanceStore.swift          (theme + folder appearances)
 │    ├── EditorDisplayStore.swift       (fonts, sizes, line height, hide-while-editing)
 │    ├── StartupStore.swift             (onBootCommand, launchBehavior, daily notes config)
 │    ├── FilteringStore.swift           (extensions, hidden patterns, gitignore, file tree mode) + filter logic
 │    ├── SidebarLayoutStore.swift       (sidebars, heights, collapsed sets) + mutation methods
 │    ├── PinnedItemsStore.swift         (pinnedItems)
 │    ├── VaultPathsStore.swift          (vaultPaths, lastNoteFolderPerVault) + discoverVaultPath()
 │    ├── SyncStore.swift                (autoSave, autoPush)
 │    ├── GitCredentialsStore.swift      (githubPAT — note: sensitive)
 │    └── BrowserStore.swift             (browserStartupURL)
 │
 ├── Sidebar/
 │    ├── SidebarPane.swift              (enum + SidebarNotePane + SidebarPaneItem)
 │    ├── Sidebar.swift                  (Sidebar + FixedSidebar + SidebarPosition)
 │    └── SidebarPaneItem+Codable.swift  (custom encoding)
 │
 ├── LaunchBehavior.swift
 │
 └── Persistence/
      ├── SettingsPersistence.swift      (~200 — owns save/flush, debounce, SaveSnapshot)
      ├── VaultConfig.swift              (~120 — vault YAML codable)
      ├── GlobalConfig.swift             (~60 — global YAML codable)
      ├── LegacyConfig.swift             (~60 — legacy JSON codable; migration only)
      └── SettingsMigration.swift        (~150 — applyLegacy/applyVault/applyGlobal/applyNoVaultDefaults)

Phased plan

Each phase is one PR, preserves the on-disk format, and can be reverted.

Phase 0 — Safety net

  • Golden-file tests: given a vault YAML + global YAML, load into SettingsManager, immediately save, assert bit-identical output. Do the same for the legacy JSON format.
  • These tests guard the entire refactor.

Phase 1 — Extract sidebar types

  • Move SidebarPane, SidebarNotePane, SidebarPaneItem, Sidebar, SidebarPosition, FixedSidebar, and the SidebarPaneItem Codable impl to Settings/Sidebar/.
  • Move LaunchBehavior to its own file.
  • Pure file moves, no behavior change. Removes ~230 lines from the main file.

Phase 2 — Extract persistence

  • Move Config, VaultConfig, GlobalConfig, SaveSnapshot to Settings/Persistence/.
  • Create SettingsPersistence class owning the debounce DispatchWorkItem, save queue, save/flush/flushIfNeeded methods.
  • SettingsManager now owns a SettingsPersistence instance and delegates to it — still a god object, but persistence is no longer mixed in. Interim state.

Phase 3 — Extract migration

  • applyLegacyConfig, applyVaultConfig, applyGlobalConfig, applyNoVaultDefaultsSettingsMigration.swift.
  • These are stateless transformations from a Config → a SettingsManager. Easy to test with fixture configs.

Phase 4 — Introduce domain stores, one at a time

Each sub-phase extracts one store, behind a facade, without changing the public API that views depend on. This lets us migrate views incrementally (Phase 5) while keeping the codebase shippable between PRs.

Order (smallest/safest first):

  1. BrowserStore (1 property)
  2. GitCredentialsStore (1 property, sensitive — careful with logging)
  3. SyncStore (2 properties)
  4. VaultPathsStore (2 properties + discoverVaultPath)
  5. PinnedItemsStore (1 property)
  6. EditorDisplayStore (5 properties)
  7. StartupStore (7 properties)
  8. FilteringStore (4 properties + filter logic)
  9. AppearanceStore (5 properties + theme resolution)
  10. SidebarLayoutStore (4 properties + 8 mutation methods — largest; do last)

During Phase 4, SettingsManager retains its public properties as computed pass-throughs to the appropriate store so views keep compiling.

Phase 5 — Migrate view call sites

  • Audit the ~36 call sites that read appState.settings.*. For each view:
    • If it reads one domain: inject the specific store via @EnvironmentObject.
    • If it reads multiple: inject multiple stores.
  • Delete the compatibility pass-throughs from Phase 4 as they become unused.

Phase 6 — Rename to SettingsCoordinator

  • The shell of SettingsManager is now just a coordinator owning ~10 stores + persistence.
  • Rename file and class. Update AppState / new chore: refactor god object #237 coordinator to hold SettingsCoordinator instead.
  • Target: <200 lines.

Testability milestones

  • After Phase 0: load/save round-trip is guarded.
  • After Phase 2: SettingsPersistence tests cover debounce, flush ordering, format selection (vault vs global vs legacy).
  • After Phase 3: migration tests cover legacy → current, vault-less → vault-first.
  • After Phase 4.i: each store is constructible in a test with no dependencies, verifies its own didSet → persistence notification.

Performance wins

  • Views observing only EditorDisplayStore no longer re-render when pinned items change.
  • SidebarLayoutStore mutations (which are the heaviest — full sidebars: [Sidebar] array rewrite on every pane move) stop invalidating the entire settings surface.
  • Debounced save can batch per-store or globally — TBD during Phase 2.

Risks

  • On-disk schema preservation. The Phase 0 golden tests are mandatory; YAML key ordering and JSON field shape must stay identical or existing users' settings files become unreadable.
  • didSet cascade. Currently every setting triggers save(). When splitting into stores, we need a single debounce per save — not N debounces. SettingsPersistence must expose a scheduleSave() that all stores call.
  • GitHub PAT handling. githubPAT is sensitive and currently lives in GlobalConfig (machine-local). Extraction must not accidentally log it or write it to the vault YAML. Consider moving it to the keychain while touching it — but scope that as a separate issue if it grows.
  • Migration code is ugly but correct. Do not "clean up" applyLegacyConfig logic during extraction; preserve field-for-field.
  • Parallel with chore: refactor god object #237. Both refactors touch the boundary where AppState.settings is accessed. Coordinate: land chore: refactor god object #237 Phase 1 (stop re-render storm) before starting this refactor's Phase 5 (view migration), so we're not migrating views twice.

Out of scope

  • Keychain migration for githubPAT — consider as a follow-up.
  • Changing the YAML / JSON schema or defaults.
  • Reworking theme data model (activeTheme, allThemes, customThemes) — separate concern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions