Skip to content

chore: refactor god object #237

@dep

Description

@dep

Goal

Break up AppState (3,702 lines, 46 @Published properties, 156 methods, observed by ~20 views) so that:

  1. Each sub-domain (vault, editor, navigation, git, fs-watching, file ops) is an independently ownable, independently testable unit.
  2. SwiftUI invalidations are scoped — a cursor move does not re-render the file tree.
  3. Main-thread file I/O on the render path is eliminated.
  4. FSEvents flood is coalesced before it hits the UI.
  5. The scanCounter race (AppState.swift:300) is replaced with real task cancellation.

Non-goals: no feature changes, no visible UX changes, no rewrite of EditorView / FileTreeView internals. This is pure surgery on ownership and data flow.


Current state (evidence)

  • AppState.swift — 3,702 lines, 46 @Published properties (verified via grep), 156 methods.
  • EditorView.swift 5,436 · FileTreeView.swift 1,608 · ContentView.swift 1,497 · SettingsManager.swift 1,470 — top offenders, all bloated partly because they reach into AppState directly.
  • VaultIndex, EditorState, NavigationState already exist as sub-objects on AppState but are not the source of truth — AppState republishes duplicates (tabs, activeTabIndex, selectedFile, isDirty, etc.) and forwards their objectWillChange back into its own publisher at AppState.swift:378-421. That forwarding is why a cursor tick re-renders every view observing AppState.
  • ~20 view files carry @EnvironmentObject var appState: AppState (verified) — each is a coupling site that must be migrated.
  • FSEvents, gitQueue, scanQueue, timers, and synchronous FileManager directory listings (e.g. flat-navigator at AppState.swift:3550-3700) all live inside AppState.
  • scanCounter: Int at AppState.swift:300 is a non-atomic cancellation token — scans run to completion and get discarded, not cancelled.

Target architecture

SynapseApp
 ├── VaultIndex        (ObservableObject)  — rootURL, allFiles, allProjectFiles, isIndexing
 ├── EditorState       (ObservableObject)  — selectedFile, fileContent, isDirty, cursor, scroll
 ├── NavigationState   (ObservableObject)  — tabs, activeTabIndex, splitOrientation, activePaneIndex, history
 ├── GitStatusStore    (ObservableObject)  — gitSyncStatus, gitBranch, gitAheadCount
 ├── CommandPaletteState (ObservableObject) — palette/search/prompt flags
 │
 ├── FileService       (actor or @MainActor class) — create/rename/move/delete, template expansion
 ├── VaultScanner      (actor)               — cancellable vault scans, publishes to VaultIndex
 ├── VaultWatcher      (actor)               — FSEvents → coalesced change batches
 ├── GitService        (already exists, promote to injected dep)
 ├── AutoSaveCoordinator                     — debounced save + sync, owns its own Task
 │
 └── AppCoordinator    (small, ~200 lines)   — wires services to stores, owns lifecycle

Views inject only the store(s) they actually read from. No more blanket @EnvironmentObject var appState.


Phased plan

Each phase ships independently, compiles, passes tests, and can be reverted. Do not batch phases into one PR.

Phase 0 — Safety net (1 PR)

  • Snapshot current behavior: record a short screen capture of vault open, tab switch, file rename, git sync for comparison.
  • Add a minimal AppStateSmokeTests target that exercises: open vault, create note, rename file, switch tabs, toggle edit mode. These are the regression tripwires for every subsequent phase.
  • Add an Instruments trace (Time Profiler + SwiftUI template) baseline on a 5k-file vault. Save the .trace file in macOS/perf-baselines/ so later phases can diff against it.

Done when: smoke tests pass on main and a baseline trace exists.

Phase 1 — Stop the re-render storm (1 PR, highest ROI)

The single highest-value change. No API moves yet — just cut the blast radius.

  • Delete the objectWillChange forwarding at AppState.swift:378-421. Sub-object changes should not invalidate AppState observers.
  • Remove the duplicated @Published properties from AppState that already live on sub-objects: selectedFile, fileContent, isDirty, tabs, activeTabIndex, allFiles, allProjectFiles, rootURL, isIndexing, splitOrientation, activePaneIndex. Replace reads with appState.editor.selectedFile etc.
  • Expose the sub-objects as @Published themselves (so SwiftUI observes the sub-object, not AppState) and update views to use @ObservedObject var editor = appState.editor (or pull them out of @EnvironmentObject).

Done when: Instruments shows the file tree does not invalidate on a cursor move, and the smoke tests still pass.

Phase 2 — Extract FileService (1 PR)

  • Create FileService as a @MainActor class (or actor if none of its callers need sync access — audit call sites first).
  • Move from AppState: createNote, createFolder, renameFile, moveFile, deleteFile, template expansion, root-note creation, and the flat-navigator directory listing at AppState.swift:3550-3700.
  • FileService holds no state — it takes a VaultIndex and mutates it via explicit methods. Results are async throws.
  • Inject via environment: .environmentObject(fileService) in SynapseApp.
  • Migrate call sites in FileTreeView, EditorView, CommandPaletteView, ContentView — direct appState.createNote(...) becomes fileService.createNote(...).

Done when: AppState no longer contains any FileManager calls; FileService has unit tests that don't need AppState.

Phase 3 — Extract VaultScanner + VaultWatcher with real cancellation (1 PR)

Fixes both the scanCounter race and the FSEvents flood.

  • VaultScanner is an actor exposing func scan(root: URL) async throws -> VaultSnapshot. Callers hold the returned Task and cancel it on re-scan. Replace scanCounter: Int entirely — delete AppState.swift:300 and the counter check logic.
  • VaultWatcher is an actor owning the FSEventStreamRef (currently at AppState.swift:594-603). It emits AsyncStream<Set<URL>> batches, coalesced with a 100-200ms debounce. No more per-event DispatchQueue.main.async.
  • AppCoordinator glues them: watcher stream → scanner → VaultIndex update (on @MainActor).
  • gitQueue / scanQueue on AppState can be deleted.

Done when: opening a 5k-file vault while git pull runs produces one UI update, not hundreds. Instruments confirms main-thread time drop.

Phase 4 — Split remaining AppState state into focused stores (1-2 PRs)

  • GitStatusStore: gitSyncStatus, gitBranch, gitAheadCount, and the sync status logic. Consumes GitService.
  • CommandPaletteState: isCommandPalettePresented, commandPaletteMode, isNewNotePromptRequested, isNewFolderPromptRequested, targetDirectoryForTemplate, targetDirectoryForNewNote, pendingTemplateRename, isRootNoteSheetPresented, isSearchPresented, pendingSearchQuery.
  • EditorSignals (or fold into EditorState): the ephemeral pendingCursorPosition, pendingCursorRange, pendingCursorTargetPaneIndex, pendingScrollOffsetY — these are the "consume pending" signals that EditorView.swift:6-36 nulls out. Move the consume helpers into EditorState itself so views don't reach across objects.
  • Migrate view call sites one store at a time. Each store migration is its own PR.

Done when: every @Published that was in AppState lives in exactly one store, no duplication.

Phase 5 — AutoSaveCoordinator + kill the Timer soup (1 PR)

  • Replace the auto-save Timer and git-sync Timer with a single AutoSaveCoordinator that owns two Tasks driven by AsyncStreams of editor-dirty events and a periodic clock.
  • Debounce properly: no save fires while the user is mid-keystroke.
  • All DispatchQueue.main.async trampolines in the save path become await MainActor.run { ... } or @MainActor methods.

Done when: no Timer instances remain in the state layer; saves are driven by async sequences.

Phase 6 — Delete the shell, finalize AppCoordinator (1 PR)

  • What remains of AppState should be a thin AppCoordinator (~200 lines) that owns the stores and services and wires lifecycle (vault open/close).
  • Rename AppStateAppCoordinator, delete the file's dead code.
  • Remove @EnvironmentObject var appState from every view that no longer needs it — views should inject only the specific stores they use. Target: fewer than 5 views still reference the coordinator directly.

Done when: AppState.swift is gone and the coordinator is <300 lines.


Testability milestones

  • After Phase 2: FileService tests run without instantiating the app.
  • After Phase 3: VaultScanner tests run against a temp directory, no AppState.
  • After Phase 4: each store is constructible in a test with zero dependencies.
  • After Phase 6: ContentView / FileTreeView previews work with hand-built stores, no file system required.

Performance verification

Re-run the Phase 0 Instruments trace after each phase and attach the diff to the PR. Specific targets:

  • Phase 1: cursor move stops invalidating file tree (SwiftUI template shows the body-recompute count drop).
  • Phase 3: FSEvents storm during git pull collapses from hundreds of main-thread hops to single-digit.
  • Phase 5: no sync file I/O on the main thread during typing (Time Profiler).

Risks & rollback

  • Biggest risk: Phase 1 is a behavior-preserving but widespread change. Land it behind the smoke tests from Phase 0 and revert cleanly if any regression appears.
  • Second risk: view migrations touch ~20 files each phase — keep PRs reviewable by migrating one store at a time.
  • Third risk: EditorView's "consume pending" pattern (EditorView.swift:6-36) is subtle; when moving those signals to EditorState, preserve the exact read-and-null semantics or cursor/scroll restoration will break.

Out of scope (follow-ups)

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