Skip to content

Architecture

lukataylor-pixel edited this page May 3, 2026 · 2 revisions

Architecture

For contributors. If you just want to use Soffit, the Quickstart is more useful.

Soffit is intentionally small. Four primitives carry the layout weight; a workspace index supplies the navigation features; everything else plugs in around them.

The four primitives

LayoutTree — the immutable pane graph

Sources/Soffit/Layout/LayoutTree.swift

indirect enum LayoutTree: Hashable, Codable {
    case empty
    case leaf(Pane)
    case split(id: SplitID, orientation: Orientation, ratio: CGFloat,
               first: LayoutTree, second: LayoutTree)
}

Recursive enum, three cases. All mutations return a new tree (addingTab, removingTab, splittingPane, closingPane, settingActiveTab, settingRatio, replacingPanel, updatingPanelState).

Pane — a tab strip

Sources/Soffit/Layout/Pane.swift

Holds tabs: [Panel] and an activeTabID. Closing the last tab causes the pane to become empty, which causes removingTab to drop the leaf, which causes the sibling subtree to promote up.

Panel — the serialised tab

Sources/Soffit/Layout/Panel.swift

struct Panel: Codable, Hashable, Identifiable {
    let id: PanelID
    var source: String
    var title: String
    var state: Data?
}

The provider for each panel is looked up by URI scheme. The state: Data? is a serialised, provider-defined blob.

PanelProvider — the plugin protocol

Sources/Soffit/Providers/PanelProvider.swift

protocol PanelProvider {
    static var scheme: String { get }
    static var displayName: String { get }
    func makeView(for source: PanelSource, context: PanelContext) -> AnyView
}

See Providers.

Stores

LayoutStore

@MainActor, owns the @Published tree and focusedPane. The 300ms debounce on $tree writes the layout snapshot to disk.

WorkspaceStore

Wraps a workspace root URL and a flat listing of its top-level entries. An FSEventsWatcher triggers refresh() on disk changes. The static helper readDirectory(_:) is nonisolated so providers can call it from background tasks.

WorkspaceIndex

The keystone for v0.4 navigation. Walks every .md / .markdown / .mdx file in the workspace, parses each via MarkdownParser, and exposes:

  • searchByName(_:) — fuzzy across filename, title, headings
  • searchByContent(_:) — full-text grep against cached lowercase content
  • backlinksTo(_:) — files whose wiki-links resolve to a given URL
  • filesWithTag(_:) — files containing a given tag
  • resolve(wikilink:) — wiki-link target → file URL
  • allKnownStems — for autocomplete

Indexing runs on Task.detached so the UI never blocks. The first build for a 5,000-file workspace takes ~2s; subsequent FSEvents rebuilds are bounded by the FSEventStream's 0.5s latency.

MarkdownParser

Pure value-type parser. Pulls frontmatter, headings, wiki-links, tags, inline links, and word count out of markdown source in one pass. Doesn't try to render — that's MarkdownUI's job.

Strips fenced code blocks before scanning for #tag so we don't pick up #include from C examples.

RecentFilesStore

UserDefaults-backed list of the last 20 opened files. MRU dedup, auto-prunes deleted files on expand.

GitStatusService

Shells out to git -C <root> status --porcelain on FSEvents nudges + a 10-second timer. Maps file URLs to { modified, untracked, staged, conflicted, ignored, clean }. Surfaced as coloured dots in the file tree.

SnippetsStore

Reads ~/.soffit/snippets.json, watches the file via FSEvents, expands triggers (,date<space> etc.) at the editor level. Built-in snippets ship as constants; user snippets override.

ThemesLoader

Reads ~/.soffit/themes/*.json. Theme picker UI is wired but only the system theme is currently applied to MarkdownUI's render — full theme application is a future pass.

Per-panel registries

Three singletons keyed on PanelID:

Registry Owns
CanvasStateRegistry CanvasStore per folder panel
MarkdownStateRegistry MarkdownActiveState per markdown panel
InitialStateHolder The on-load Data? blob so cold-started panels can restore their state

AppServices cleans these up on every tree mutation by diffing panelIDs.

Rendering

LayoutHostViewLayoutTreeViewPaneView / SplitHost

Recursive SwiftUI views matching the tree shape. SplitHost wraps NSSplitViewRepresentable, which holds two NSHostingController children to host the SwiftUI subtrees. Splits use a custom InvisibleSplitView (drawDivider is empty) so the gradient backdrop shows through the gutter.

Why AppKit + SwiftUI hybrid?

Pure SwiftUI HSplitView / VSplitView are flaky around nested split-view dragging and geometry on macOS. AppKit's NSSplitView is rock-solid but only handles two arranged subviews per instance — perfect for a binary tree.

Performance shape

Three patterns recur across the codebase:

  1. Read-on-demand → cached in @State. Anywhere a SwiftUI view body would do disk IO, the IO moves into Task.detached(priority: .userInitiated) triggered from onAppear / onChange, with the result cached in @State. See FilePreviewCard, DocumentCard, FileTreeView.

  2. Debounced persistence. Anywhere a per-frame mutation triggers a save, the save is gated through a Combine debounce(for:scheduler:) sink. See LayoutStore (300ms tree save), CanvasStore (250ms canvas state), MarkdownPanelModel (500ms file write).

  3. Discrete ops bypass the debounce. The "immediate" path on CanvasStore (add, remove, color change, mode toggle) cancels any pending debounce and writes synchronously, then re-arms the sink.

Markdown highlighter

MarkdownHighlighter runs seven regexes against an NSTextStorage. applyIncremental(to:style:editedRange:) expands the edit range to ~5 lines on either side via NSString.lineRange(for:), then re-scans only that scope. Falls back to full-file when the scope contains a ``` boundary.

This is the single biggest perf win of v0.3 — for a 10k-line PRD, per-keystroke highlight time drops from ~50ms (full file regex × 7) to <1ms (5-line scope).

Tests

Tests/SoffitTests/. Four suites, 36 tests:

  • LayoutTreeTests — split/close/insert/remove/ratio/state/Codable.
  • CanvasStateTests — defaults, codable, viewport preservation, sticky note edits, registry reuse.
  • MarkdownHighlighterTests — full and incremental highlight, paragraph scope, code-fence fallback, large-document smoke.
  • PanelLifecycleTests — registry cleanup, replacePanel identity, tab/pane removal, debounced canvas persistence convergence.

UI gestures (drag, drop zones, animation) are not unit-tested; live testing against examples/ covers those.

Key data flow

user action
   ↓
LayoutStore mutation (mainactor, sync)
   ↓
@Published tree fires objectWillChange
   ↓
SwiftUI re-renders the affected pane subtrees
   ↓
debounced sink picks up the new tree (300ms)
   ↓
JSON-encoded snapshot written atomically to disk

For the workspace index:

file changed (FSEvents)
   ↓
WorkspaceStore.refresh fires (debounced 400ms)
   ↓
WorkspaceIndex.refreshAll on Task.detached
   ↓
each .md re-parsed via MarkdownParser
   ↓
@Published files / allTags update
   ↓
search palette / sidebar tag list / backlinks panel re-render

Building and shipping

swift build -c release produces .build/release/Soffit — runnable but not bundled. To get a proper .app:

./scripts/build-app.sh release          # wraps in Soffit.app with Info.plist + icon
./scripts/build-dmg.sh                  # makes a distributable DMG

The icon is generated programmatically by scripts/generate-icon.swift.

The DMG is ad-hoc signed but not Apple-notarized. First-run on another Mac requires right-click → Open → Open in the Gatekeeper dialog.