-
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 layout weight; a workspace index supplies the navigation features; 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).
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.
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.
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.
@MainActor, owns the @Published tree and focusedPane. 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.
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.
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.
UserDefaults-backed list of the last 20 opened files. MRU dedup, auto-prunes deleted files on expand.
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.
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.
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.
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.
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.
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.
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/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
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
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.
The DMG is ad-hoc signed but not Apple-notarized. First-run on another Mac requires right-click → Open → Open in the Gatekeeper dialog.
Soffit · MIT-licensed · macOS 14+
Start here
Reference