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 weight; 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).

Why an enum over a node graph?

  • Free Hashable / Equatable synthesis (so the removeDuplicates() Combine debounce trivially deduplicates noisy publishes).
  • Free Codable round-trip means persistence is JSONEncoder().encode(tree).
  • Pure value mutations are dead-easy to unit test.

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. This cascade is the only reason "close last tab" doesn't leave a UI dead-end.

Panel — the serialised tab

Sources/Soffit/Layout/Panel.swift

struct Panel: Codable, Hashable, Identifiable {
    let id: PanelID
    var source: String   // URI: "file:///…", "folder:///…", "https://…", "mermaid:///…", "terminal:///…"
    var title: String
    var state: Data?     // opaque per-provider state blob
}

The provider for each panel is looked up by URI scheme. The state: Data? is a serialised, provider-defined blob. It round-trips through the layout snapshot. Used by markdown panels (mode), folder panels (canvas item positions), legacy chat panels (message history).

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
}

Registered by URI scheme. See Providers for the catalog of shipping providers.

Stores

LayoutStore (Layout/LayoutStore.swift)

@MainActor, owns the @Published tree and focusedPane. All mutations from the UI flow through this object so objectWillChange fires once per user action. The 300ms debounce on $tree writes the layout snapshot to disk.

WorkspaceStore (Workspace/WorkspaceStore.swift)

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.

RecentFilesStore (Workspace/RecentFilesStore.swift)

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

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

These get cleaned up by AppServices when a panel disappears from the tree (it diffs panelIDs on every tree change).

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. So:

  • Layout shell: NSSplitView (binary, nested, hand-rolled delegate)
  • Pane content: SwiftUI throughout

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. This keeps tests deterministic and gives the user the feeling that one-shot actions are durable immediately.

Markdown highlighter

MarkdownHighlighter runs seven regexes against a NSTextStorage to apply syntax styling:

  • Headings (# … )
  • Bold (**…**)
  • Italic (*…* with negative lookaround for bold)
  • Inline code (`…`)
  • Fenced code blocks
  • Links ([text](url))
  • List markers
  • Block quotes

apply(to:style:) re-scans the entire storage. applyIncremental(to:style:editedRange:) expands the edit range to roughly 5 lines on either side via NSString.lineRange(for:), then re-scans only that scope. If the expanded scope contains a ``` (code-fence boundary), it falls back to a full re-scan to keep fence pairing correct.

The Coordinator for MarkdownSourceEditor conforms to NSTextStorageDelegate, captures the editedRange from textStorage(_:didProcessEditing:range:changeInLength:), and feeds it to applyIncremental from textDidChange(_:).

This is the single biggest perf win of v0.3 — for a 10k-line PRD, per-keystroke highlight time goes 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

The debounce is the linchpin — it absorbs the burst of intermediate states a single user action produces (e.g., dragging a tab generates dozens of objectWillChange events; one disk write at the end captures the resting state).

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 — gradient squircle with a 2x2 grid mark, rendered at 10 macOS-required sizes, compiled to .icns via iconutil.

The DMG is ad-hoc signed but not Apple-notarized. First-run on another Mac requires right-click → Open → Open in the Gatekeeper dialog. Notarization needs a paid Apple Developer cert; that's intentionally out of scope.

Clone this wiki locally