Goal
Break up AppState (3,702 lines, 46 @Published properties, 156 methods, observed by ~20 views) so that:
- Each sub-domain (vault, editor, navigation, git, fs-watching, file ops) is an independently ownable, independently testable unit.
- SwiftUI invalidations are scoped — a cursor move does not re-render the file tree.
- Main-thread file I/O on the render path is eliminated.
- FSEvents flood is coalesced before it hits the UI.
- 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
AppState → AppCoordinator, 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)
Goal
Break up
AppState(3,702 lines, 46@Publishedproperties, 156 methods, observed by ~20 views) so that:scanCounterrace (AppState.swift:300) is replaced with real task cancellation.Non-goals: no feature changes, no visible UX changes, no rewrite of
EditorView/FileTreeViewinternals. This is pure surgery on ownership and data flow.Current state (evidence)
AppState.swift— 3,702 lines, 46@Publishedproperties (verified via grep), 156 methods.EditorView.swift5,436 ·FileTreeView.swift1,608 ·ContentView.swift1,497 ·SettingsManager.swift1,470 — top offenders, all bloated partly because they reach into AppState directly.VaultIndex,EditorState,NavigationStatealready exist as sub-objects on AppState but are not the source of truth — AppState republishes duplicates (tabs,activeTabIndex,selectedFile,isDirty, etc.) and forwards theirobjectWillChangeback into its own publisher atAppState.swift:378-421. That forwarding is why a cursor tick re-renders every view observing AppState.@EnvironmentObject var appState: AppState(verified) — each is a coupling site that must be migrated.gitQueue,scanQueue, timers, and synchronousFileManagerdirectory listings (e.g. flat-navigator atAppState.swift:3550-3700) all live inside AppState.scanCounter: IntatAppState.swift:300is a non-atomic cancellation token — scans run to completion and get discarded, not cancelled.Target architecture
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)
AppStateSmokeTeststarget that exercises: open vault, create note, rename file, switch tabs, toggle edit mode. These are the regression tripwires for every subsequent phase..tracefile inmacOS/perf-baselines/so later phases can diff against it.Done when: smoke tests pass on
mainand 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.
objectWillChangeforwarding atAppState.swift:378-421. Sub-object changes should not invalidate AppState observers.@Publishedproperties from AppState that already live on sub-objects:selectedFile,fileContent,isDirty,tabs,activeTabIndex,allFiles,allProjectFiles,rootURL,isIndexing,splitOrientation,activePaneIndex. Replace reads withappState.editor.selectedFileetc.@Publishedthemselves (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)FileServiceas a@MainActorclass (oractorif none of its callers need sync access — audit call sites first).createNote,createFolder,renameFile,moveFile,deleteFile, template expansion, root-note creation, and the flat-navigator directory listing atAppState.swift:3550-3700.VaultIndexand mutates it via explicit methods. Results areasync throws..environmentObject(fileService)inSynapseApp.FileTreeView,EditorView,CommandPaletteView,ContentView— directappState.createNote(...)becomesfileService.createNote(...).Done when: AppState no longer contains any
FileManagercalls; FileService has unit tests that don't need AppState.Phase 3 — Extract
VaultScanner+VaultWatcherwith real cancellation (1 PR)Fixes both the
scanCounterrace and the FSEvents flood.VaultScanneris anactorexposingfunc scan(root: URL) async throws -> VaultSnapshot. Callers hold the returnedTaskand cancel it on re-scan. ReplacescanCounter: Intentirely — deleteAppState.swift:300and the counter check logic.VaultWatcheris anactorowning theFSEventStreamRef(currently atAppState.swift:594-603). It emitsAsyncStream<Set<URL>>batches, coalesced with a 100-200ms debounce. No more per-eventDispatchQueue.main.async.AppCoordinatorglues them: watcher stream → scanner →VaultIndexupdate (on@MainActor).gitQueue/scanQueueon AppState can be deleted.Done when: opening a 5k-file vault while
git pullruns 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. ConsumesGitService.CommandPaletteState:isCommandPalettePresented,commandPaletteMode,isNewNotePromptRequested,isNewFolderPromptRequested,targetDirectoryForTemplate,targetDirectoryForNewNote,pendingTemplateRename,isRootNoteSheetPresented,isSearchPresented,pendingSearchQuery.EditorSignals(or fold intoEditorState): the ephemeralpendingCursorPosition,pendingCursorRange,pendingCursorTargetPaneIndex,pendingScrollOffsetY— these are the "consume pending" signals thatEditorView.swift:6-36nulls out. Move the consume helpers intoEditorStateitself so views don't reach across objects.Done when: every
@Publishedthat was in AppState lives in exactly one store, no duplication.Phase 5 —
AutoSaveCoordinator+ kill theTimersoup (1 PR)Timerand git-syncTimerwith a singleAutoSaveCoordinatorthat owns twoTasks driven byAsyncStreams of editor-dirty events and a periodic clock.DispatchQueue.main.asynctrampolines in the save path becomeawait MainActor.run { ... }or@MainActormethods.Done when: no
Timerinstances remain in the state layer; saves are driven by async sequences.Phase 6 — Delete the shell, finalize
AppCoordinator(1 PR)AppCoordinator(~200 lines) that owns the stores and services and wires lifecycle (vault open/close).AppState→AppCoordinator, delete the file's dead code.@EnvironmentObject var appStatefrom 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.swiftis gone and the coordinator is <300 lines.Testability milestones
FileServicetests run without instantiating the app.VaultScannertests run against a temp directory, no AppState.ContentView/FileTreeViewpreviews 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:
git pullcollapses from hundreds of main-thread hops to single-digit.Risks & rollback
EditorView's "consume pending" pattern (EditorView.swift:6-36) is subtle; when moving those signals toEditorState, preserve the exact read-and-null semantics or cursor/scroll restoration will break.Out of scope (follow-ups)
EditorView.swift(5,436 lines) — chore: break up EditorView.swift (5,436 lines) #238FileTreeView.swift(1,608 lines) — chore: break up FileTreeView.swift (1,608 lines) #239SettingsManagerto the same pattern — chore: split SettingsManager into focused stores (1,470 lines) #240