-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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/Equatablesynthesis (so theremoveDuplicates()Combine debounce trivially deduplicates noisy publishes). - Free
Codableround-trip means persistence isJSONEncoder().encode(tree). - Pure value mutations are dead-easy to unit test.
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.
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).
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.
@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.
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.
UserDefaults-backed list of the last 20 opened files. MRU dedup, auto-prunes deleted files on expand.
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).
LayoutHostView → LayoutTreeView → PaneView / 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.
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
Three patterns recur across the codebase:
-
Read-on-demand → cached in
@State. Anywhere a SwiftUI view body would do disk IO, the IO moves intoTask.detached(priority: .userInitiated)triggered fromonAppear/onChange, with the result cached in@State. SeeFilePreviewCard,DocumentCard,FileTreeView. -
Debounced persistence. Anywhere a per-frame mutation triggers a save, the save is gated through a Combine
debounce(for:scheduler:)sink. SeeLayoutStore(300ms tree save),CanvasStore(250ms canvas state),MarkdownPanelModel(500ms file write). -
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.
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/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.
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).
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 DMGThe 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.
Soffit · MIT-licensed · macOS 14+
Start here
Reference