krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

docs/plans/2026-10-04-viewer.md

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/docs/plans/2026-10-04-viewer.md rendered · source · history · blame · raw

1684 lines · 69681 bytes

8 symbols in this file

Read-only Viewer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: The phase 1 Mac app: add folders, browse them in a sidebar, see the open file's outline, quick open (⌘P), full-text search, and read files in the TextKit 2 editor with org folding (TAB, Shift-TAB, #+STARTUP).

Architecture: Folding policy lives in OrgPresentation (platform-free): org's local cycle (folded, children, subtree), global cycle (overview, contents, show all) and #+STARTUP visibility, with a second fold kind (bodyFolds) that hides a heading's own body but keeps its children. OrgApp is a UI-free model library: WorkspaceModel (roots as bookmarks, index, background reconciliation, FSEvents, search, quick open) and DocumentSession (the open file, its outline, reveal and reload requests). The Orgstar executable is a SwiftUI shell (sidebar, outline, search, quick open) around the AppKit editor in read-only mode; scripts/build-app.sh makes the .app bundle.

Tech Stack: Swift 6.2 tools, SwiftUI, AppKit, TextKit 2, Swift Testing.

Spec: docs/design.md, "Phases" (phase 1 scope: Mac app, rendering, folding).

Global Constraints

  • The viewer never writes to the user's files; the editor runs with editable: false.
  • OrgApp and OrgPresentation import no AppKit or SwiftUI.
  • Decisions (user): SwiftPM executable plus a bundle script, not an Xcode project; SwiftUI shell with the AppKit editor. Bundle identifier sh.krz.orgstar.

Development hooks

ORGSTAR_DATA_DIR moves the index and roots out of Application Support; ORGSTAR_OPEN selects a file at launch; ORGSTAR_SNAPSHOT=<png> renders the window from inside the app after ORGSTAR_SNAPSHOT_DELAY seconds and quits. Rendering from inside the app needs no screen-recording permission; it does not draw the sidebar's translucent material, so the sidebar shows white in snapshots.

File structure

File Responsibility
Sources/OrgPresentation/Folding.swift FoldState, GlobalVisibility, OutlineEntry, cycling, #+STARTUP
Sources/OrgPresentation/Presentation.swift hiddenRanges with body folds
Sources/OrgDocument/ViewState.swift bodyFolds mapped and pruned with edits
Sources/OrgEditorAppKit/OrgEditor.swift Cycling, reveal, reload from disk, read-only mode, hanging indents for wrapped lines
Sources/OrgApp/FileTree.swift FileEntry, FileTreeNode, FuzzyMatch
Sources/OrgApp/WorkspaceModel.swift Roots, index, reconciliation, watching, queries
Sources/OrgApp/DocumentSession.swift Open file, outline, reveal and reload requests
Sources/Orgstar/*.swift The SwiftUI app, editor bridge and development hooks
scripts/build-app.sh Orgstar.app with Info.plist, signed ad hoc

Task 1: Folding

Files:

  • Create: Sources/OrgPresentation/Folding.swift
  • Modify: Sources/OrgPresentation/Presentation.swift, Sources/OrgDocument/ViewState.swift, Sources/OrgEditorAppKit/OrgEditor.swift
  • Test: Tests/OrgPresentationTests/PresentationTests.swift, Tests/OrgEditorAppKitTests/EditorTests.swift

Interfaces:

  • Produces: FoldState, GlobalVisibility, OutlineEntry, Presentation.outline(_:), cycle(_:in:_:), cycleGlobal(from:in:), foldState(for:in:), foldState(showingLevels:in:), startupFoldState(_:), startupOptions(_:); ViewState.bodyFolds; OrgEditor(document:frame:editable:), foldState, setFolds(_ state: FoldState), cycleAtCaret(), cycleGlobal(), reveal(_:), diskChanged(to:), hangingColumns(_:).

  • Step 1: Write the failing tests

diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift
index 494399a..cdf9b2d 100644
--- a/Tests/OrgEditorAppKitTests/EditorTests.swift
+++ b/Tests/OrgEditorAppKitTests/EditorTests.swift
@@ -1,6 +1,7 @@
 import AppKit
 import OrgCore
 import OrgDocument
+import OrgPresentation
 import Testing
 @testable import OrgEditorAppKit
 
@@ -77,16 +78,45 @@ struct FoldingTests {
         let h = Harness(outline)
         #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
         h.caret(at: 1)
-        #expect(h.editor.toggleFoldAtCaret())
+        #expect(h.editor.cycleAtCaret())
         #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
-        h.editor.toggleFoldAtCaret()
+        h.editor.cycleAtCaret()
+        #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"])
+        h.editor.cycleAtCaret()
         #expect(h.visibleLines.count == 6)
     }
 
+    @Test func globalCycleAndStartup() {
+        let h = Harness(outline)
+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
+        #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
+        #expect(h.visibleLines.count == 6)
+        #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
+    }
+
+    @Test func revealUnfoldsWhatHidesTheTarget() {
+        let h = Harness("#+STARTUP: overview\n" + outline)
+        let target = h.offset(of: "more")
+        h.editor.reveal(target)
+        #expect(h.visibleLines.contains("more\n"))
+        #expect(h.caret == target)
+    }
+
+    @Test func readOnlyAndReloadKeepFolds() {
+        let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false)
+        #expect(!editor.textView.isEditable)
+        editor.setFolds(FoldState(folds: [19]))
+        editor.diskChanged(to: Array(("new line\n" + outline).utf8))
+        #expect(editor.document.text == "new line\n" + outline)
+        #expect(editor.foldState.folds == [28])
+    }
+
     @Test func tabFoldsOnlyOnHeadings() {
         let h = Harness(outline)
         h.caret(at: 5)
-        #expect(!h.editor.toggleFoldAtCaret())
+        #expect(!h.editor.cycleAtCaret())
         h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
         #expect(h.visibleLines == ["* a\n", "* c\n"])
     }
@@ -94,7 +124,7 @@ struct FoldingTests {
     @Test func caretMovesOverFoldedText() {
         let h = Harness(outline)
         h.caret(at: 1)
-        h.editor.toggleFoldAtCaret()
+        h.editor.cycleAtCaret()
         h.caret(at: 0)
         h.textView.moveDown(nil)
         #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
@@ -108,7 +138,7 @@ struct FoldingTests {
     @Test func copyingAcrossAFoldIncludesHiddenText() {
         let h = Harness(outline)
         h.caret(at: 1)
-        h.editor.toggleFoldAtCaret()
+        h.editor.cycleAtCaret()
         h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
         let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
         defer { pasteboard.releaseGlobally() }
@@ -119,7 +149,7 @@ struct FoldingTests {
     @Test func foldsFollowEdits() {
         let h = Harness(outline)
         h.caret(at: h.offset(of: "* c") + 1)
-        h.editor.toggleFoldAtCaret()
+        h.editor.cycleAtCaret()
         h.caret(at: 0)
         h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
         #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
@@ -177,7 +207,7 @@ struct EditingTests {
     @Test func accessibilityReadsTheWholeText() {
         let h = Harness(outline)
         h.caret(at: 1)
-        h.editor.toggleFoldAtCaret()
+        h.editor.cycleAtCaret()
         #expect(h.textView.accessibilityValue() as? String == outline)
         h.textView.setSelectedRange(NSRange(location: 0, length: 3))
         #expect(h.textView.accessibilitySelectedText() == "* a")
@@ -280,3 +310,13 @@ struct InitialStylingTests {
         #expect(h.editor.styledUpTo == before + 3)
     }
 }
+
+struct HangingIndentTests {
+    @Test(arguments: [
+        ("plain", 0), ("  indented", 2), ("- item", 2), ("  - [X] done", 8), ("12. step", 4),
+        ("* heading-like", 0), ("  * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
+    ])
+    func columns(line: String, expected: Int) {
+        #expect(OrgEditor.hangingColumns(line) == expected)
+    }
+}
diff --git a/Tests/OrgPresentationTests/PresentationTests.swift b/Tests/OrgPresentationTests/PresentationTests.swift
index 6e1ca3d..7007d92 100644
--- a/Tests/OrgPresentationTests/PresentationTests.swift
+++ b/Tests/OrgPresentationTests/PresentationTests.swift
@@ -76,3 +76,47 @@ struct FoldTests {
         #expect(Presentation.headings(tree) == [0, 9, 19])
     }
 }
+
+struct CyclingTests {
+    let text = "* a\nbody\n** b\nmore\n*** c\ndeep\n* d\n"
+    var tree: OrgTree { OrgParser.parse(text) }
+
+    @Test func outline() {
+        let entries = Presentation.outline(tree)
+        #expect(entries.map(\.title) == ["a", "b", "c", "d"])
+        #expect(entries.map(\.parent) == [nil, 0, 1, nil])
+        #expect(entries.map(\.isTopLevel) == [true, false, false, true])
+    }
+
+    @Test func localCycle() {
+        var state = FoldState()
+        state = Presentation.cycle(0, in: tree, state)
+        #expect(state == FoldState(folds: [0]))
+        state = Presentation.cycle(0, in: tree, state)
+        #expect(state == FoldState(folds: [9]))
+        state = Presentation.cycle(0, in: tree, state)
+        #expect(state == FoldState())
+        #expect(Presentation.cycle(30, in: tree, FoldState(folds: [30])) == FoldState())
+    }
+
+    @Test func globalStates() {
+        #expect(Presentation.foldState(for: .overview, in: tree) == FoldState(folds: [0, 30]))
+        #expect(Presentation.foldState(for: .contents, in: tree) == FoldState(bodyFolds: [0, 9, 19, 30]))
+        #expect(Presentation.cycleGlobal(from: nil, in: tree).0 == .overview)
+        #expect(Presentation.cycleGlobal(from: .overview, in: tree).0 == .contents)
+        #expect(Presentation.cycleGlobal(from: .contents, in: tree).0 == .showAll)
+    }
+
+    @Test func bodyFoldsKeepChildHeadings() {
+        #expect(Presentation.hiddenRanges(tree, folds: [], bodyFolds: [0, 9, 19, 30]) == [4..<9, 14..<19, 25..<30])
+    }
+
+    @Test func startup() {
+        #expect(Presentation.startupFoldState(OrgParser.parse("#+STARTUP: content\n" + text)) != nil)
+        #expect(Presentation.startupFoldState(OrgParser.parse(text)) == nil)
+        let levels = OrgParser.parse("#+startup: show2levels\n" + text)
+        let state = Presentation.startupFoldState(levels)
+        #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2)
+        #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
+    }
+}
  • Step 2: Implement
import OrgCore

/// Which headings are folded: whole subtrees, or only their own bodies.
public struct FoldState: Sendable, Equatable {
    public var folds: Set<Int>
    public var bodyFolds: Set<Int>

    public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
        self.folds = folds
        self.bodyFolds = bodyFolds
    }
}

/// org's global visibility states, cycled by Shift-TAB.
public enum GlobalVisibility: Sendable, Equatable {
    /// Top-level headings only.
    case overview
    /// Every heading, no bodies.
    case contents
    case showAll
}

public struct OutlineEntry: Sendable, Equatable {
    /// Start offset of the heading line.
    public let start: Int
    public let level: Int
    /// The title as written.
    public let title: String
    /// End of the heading's section.
    public let end: Int
    /// Index of the parent entry.
    public let parent: Int?
    public let isTopLevel: Bool
}

extension Presentation {
    /// Every heading in document order.
    public static func outline(_ tree: OrgTree) -> [OutlineEntry] {
        var entries: [OutlineEntry] = []
        collectOutline(tree.root, parent: nil, topLevel: true, &entries)
        return entries
    }

    private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) {
        for section in node.children where section.kind == .section {
            guard let heading = section.firstChild(.heading) else { continue }
            let index = entries.count
            entries.append(OutlineEntry(
                start: heading.range.lowerBound,
                level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1,
                title: heading.firstChild(.title)?.text ?? "",
                end: section.range.upperBound,
                parent: parent,
                isTopLevel: topLevel
            ))
            collectOutline(section, parent: index, topLevel: false, &entries)
        }
    }

    /// TAB on a heading: folded, then its children, then the whole subtree, then folded again.
    /// A heading without children goes straight from folded to unfolded.
    public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
        let outline = outline(tree)
        guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
        let entry = outline[index]
        let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start)
        let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start)
        var next = state
        func unfoldSubtree() {
            next.folds.subtract([heading] + descendants)
            next.bodyFolds.subtract([heading] + descendants)
        }
        if state.folds.contains(heading) {
            unfoldSubtree()
            next.folds.formUnion(children)
        } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) {
            unfoldSubtree()
        } else {
            unfoldSubtree()
            next.folds.insert(heading)
        }
        return next
    }

    /// Shift-TAB: overview, contents, show all, and around.
    public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) {
        let next: GlobalVisibility = switch current {
        case .overview: .contents
        case .contents: .showAll
        case .showAll, nil: .overview
        }
        return (next, foldState(for: next, in: tree))
    }

    public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState {
        let outline = outline(tree)
        switch visibility {
        case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start)))
        case .contents: return FoldState(bodyFolds: Set(outline.map(\.start)))
        case .showAll: return FoldState()
        }
    }

    /// Headings of levels 1 through `levels` visible, no bodies.
    public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState {
        let outline = outline(tree)
        return FoldState(
            folds: Set(outline.filter { $0.level == levels }.map(\.start)),
            bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start))
        )
    }

    /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none.
    /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`...
    public static func startupFoldState(_ tree: OrgTree) -> FoldState? {
        var result: FoldState?
        for option in startupOptions(tree) {
            switch option.lowercased() {
            case "overview", "fold": result = foldState(for: .overview, in: tree)
            case "content": result = foldState(for: .contents, in: tree)
            case "showall", "nofold", "showeverything": result = FoldState()
            case let word where word.hasPrefix("show") && word.hasSuffix("levels"):
                if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 {
                    result = foldState(showingLevels: levels, in: tree)
                }
            default: break
            }
        }
        return result
    }

    /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside
    /// blocks and paragraphs is never read as a setting.
    public static func startupOptions(_ tree: OrgTree) -> [String] {
        var options: [String] = []
        func visit(_ node: GreenNode) {
            for case .node(let child) in node.children {
                switch child.kind {
                case .keyword:
                    let text = child.text
                    if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"),
                       let colon = text.firstIndex(of: ":") {
                        options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init)
                    }
                case .document, .zerothSection, .section, .drawer:
                    visit(child)
                default:
                    break
                }
            }
        }
        visit(tree.green)
        return options
    }
}
diff --git a/Sources/OrgDocument/ViewState.swift b/Sources/OrgDocument/ViewState.swift
index 9cdf35a..6f84444 100644
--- a/Sources/OrgDocument/ViewState.swift
+++ b/Sources/OrgDocument/ViewState.swift
@@ -3,12 +3,15 @@ import OrgCore
 /// Per-window state for one document: selection and folds, in UTF-16 offsets.
 public struct ViewState: Sendable, Equatable {
     public var selection: [Range<Int>]
-    /// Start offsets of folded headings.
+    /// Start offsets of headings whose whole subtree is folded.
     public var folds: Set<Int>
+    /// Start offsets of headings whose own body is folded; their child headings stay visible.
+    public var bodyFolds: Set<Int>
 
-    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
+    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
         self.selection = selection
         self.folds = folds
+        self.bodyFolds = bodyFolds
     }
 
     /// Maps through non-overlapping edits given in old coordinates.
@@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable {
         let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
         return ViewState(
             selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
-            folds: Set(folds.map { mapOffset($0, sorted) })
+            folds: Set(folds.map { mapOffset($0, sorted) }),
+            bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
         )
     }
 
     /// Drops folds that no longer sit at the start of a heading.
     public func pruned(to tree: OrgTree) -> ViewState {
-        guard !folds.isEmpty else { return self }
-        return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
+        guard !folds.isEmpty || !bodyFolds.isEmpty else { return self }
+        return ViewState(
+            selection: selection,
+            folds: folds.filter { isHeadingStart($0, tree.root) },
+            bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) }
+        )
     }
 }
 
diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift
index b7c3dc4..2a52c5e 100644
--- a/Sources/OrgEditorAppKit/OrgEditor.swift
+++ b/Sources/OrgEditorAppKit/OrgEditor.swift
@@ -65,12 +65,13 @@ public final class OrgEditor: NSObject {
     /// Styled per run-loop turn afterwards.
     static let styleChunk = 200_000
 
-    public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
+    public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
         self.document = document
         textView = NSTextView(usingTextLayoutManager: true)
         super.init()
         textView.frame = frame
         textView.isRichText = false
+        textView.isEditable = editable
         textView.allowsUndo = true
         textView.isAutomaticQuoteSubstitutionEnabled = false
         textView.isAutomaticDashSubstitutionEnabled = false
@@ -87,6 +88,7 @@ public final class OrgEditor: NSObject {
         textContentStorage.delegate = self
 
         load(document.text)
+        if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
     }
 
     /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -153,37 +155,77 @@ public final class OrgEditor: NSObject {
         if document.text != before {
             let edits = lineEdits(from: before, to: document.text)
             load(document.text)
-            setFolds(view.mapped(through: edits).folds)
+            let mapped = view.mapped(through: edits)
+            setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
         }
         return outcome
     }
 
+    // MARK: - Disk
+
+    /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
+    /// its headings.
+    public func diskChanged(to bytes: [UInt8]) {
+        let edits: [TextEdit]
+        switch document.diskChanged(to: bytes) {
+        case .reloaded(let changes), .merged(let changes): edits = changes
+        case .unchanged, .conflict: return
+        }
+        let mapped = view.mapped(through: edits)
+        load(document.text)
+        setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
+    }
+
     // MARK: - Folding
 
-    public func setFolds(_ folds: Set<Int>) {
-        view.folds = folds
+    public var foldState: FoldState {
+        FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
+    }
+
+    private var globalVisibility: GlobalVisibility?
+
+    public func setFolds(_ state: FoldState) {
+        view.folds = state.folds
+        view.bodyFolds = state.bodyFolds
         view = view.pruned(to: document.tree)
-        folded.set(view.folds)
-        hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
+        updateHidden()
         textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
         textView.needsDisplay = true
         let caret = textView.selectedRange().location
         if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
     }
 
-    /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
+    private func updateHidden() {
+        folded.set(view.folds.union(view.bodyFolds))
+        hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
+    }
+
+    /// TAB on a heading: folded, children, subtree. Returns false off a heading.
     @discardableResult
-    public func toggleFoldAtCaret() -> Bool {
+    public func cycleAtCaret() -> Bool {
         guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
-        var folds = view.folds
-        if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
-        setFolds(folds)
+        setFolds(Presentation.cycle(heading, in: document.tree, foldState))
         return true
     }
 
-    /// Folds every heading, or unfolds everything if anything is folded.
-    public func toggleAllFolds() {
-        setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
+    /// Shift-TAB: overview, contents, show all.
+    public func cycleGlobal() {
+        let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
+        globalVisibility = visibility
+        setFolds(state)
+    }
+
+    /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
+    public func reveal(_ offset: Int) {
+        let target = max(0, min(offset, utf16Length))
+        var state = foldState
+        for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
+            state.folds.remove(entry.start)
+            state.bodyFolds.remove(entry.start)
+        }
+        setFolds(state)
+        setCaret(target)
+        textView.scrollRangeToVisible(NSRange(location: target, length: 0))
     }
 
     private func setCaret(_ offset: Int) {
@@ -207,9 +249,23 @@ public final class OrgEditor: NSObject {
         guard !range.isEmpty else { return }
         let whole = NSRange(range)
         storage.setAttributes(theme.base, range: whole)
+        let text = storage.string as NSString
         for run in Presentation.indents(document.tree, in: range) {
             guard let clipped = clip(run.range, to: range) else { continue }
-            storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
+            guard run.firstLine == run.wrapped else {
+                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
+                continue
+            }
+            // Body lines wrap under their own text: past leading blanks, and past a list bullet
+            // and checkbox, as adaptive-wrap does in Emacs.
+            var location = clipped.location
+            while location < NSMaxRange(clipped) {
+                let line = text.lineRange(for: NSRange(location: location, length: 0))
+                let hanging = Self.hangingColumns(text.substring(with: line))
+                let lineRange = NSIntersectionRange(line, clipped)
+                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange)
+                location = NSMaxRange(line)
+            }
         }
         for run in Presentation.styles(document.tree, in: range) {
             guard let clipped = clip(run.range, to: range) else { continue }
@@ -301,6 +357,34 @@ public final class OrgEditor: NSObject {
         onEditTiming?(clock.now - start)
     }
 
+    /// Columns a wrapped continuation of `line` should start past the line's own start.
+    nonisolated static func hangingColumns(_ line: String) -> Int {
+        var columns = 0
+        var rest = Substring(line)
+        while let c = rest.first, c == " " || c == "\t" {
+            columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
+            rest = rest.dropFirst()
+        }
+        // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
+        var bullet = 0
+        if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
+            bullet = 1
+        } else {
+            let digits = rest.prefix { $0.isASCII && $0.isNumber }
+            if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
+                bullet = digits.count + 1
+            }
+        }
+        guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
+        columns += bullet + 1
+        let afterBullet = rest.dropFirst(bullet + 1)
+        if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
+           afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
+            columns += 4
+        }
+        return columns
+    }
+
     /// Whether a heading line touches `start..<end` or starts right after it, where joining
     /// lines could take it away.
     static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
@@ -332,9 +416,9 @@ public final class OrgEditor: NSObject {
 extension OrgEditor: NSTextViewDelegate {
     public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
         switch selector {
-        case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
+        case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
         case #selector(NSResponder.insertBacktab(_:)):
-            toggleAllFolds()
+            cycleGlobal()
             return true
         default: return false
         }
diff --git a/Sources/OrgPresentation/Presentation.swift b/Sources/OrgPresentation/Presentation.swift
index 504e0b5..1813443 100644
--- a/Sources/OrgPresentation/Presentation.swift
+++ b/Sources/OrgPresentation/Presentation.swift
@@ -130,25 +130,31 @@ public enum Presentation {
         }
     }
 
-    /// Ranges to hide for folded headings, given by their start offsets: from the end of the
-    /// heading line to the end of its section. Sorted, without nested duplicates.
-    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
-        guard !folds.isEmpty else { return [] }
+    /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
+    /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
+    /// Sorted, without nested duplicates.
+    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
+        guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
         var ranges: [Range<Int>] = []
-        collectHidden(tree.root, folds, &ranges)
+        collectHidden(tree.root, folds, bodyFolds, &ranges)
         return ranges
     }
 
-    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
+    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
         for section in node.children where section.kind == .section {
-            guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
-            if folds.contains(heading.range.lowerBound) {
+            guard let heading = section.firstChild(.heading) else { continue }
+            let start = heading.range.lowerBound
+            if folds.contains(start) {
                 if heading.range.upperBound < section.range.upperBound {
                     ranges.append(heading.range.upperBound..<section.range.upperBound)
                 }
-            } else {
-                collectHidden(section, folds, &ranges)
+                continue
             }
+            if bodyFolds.contains(start) {
+                let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
+                if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) }
+            }
+            collectHidden(section, folds, bodyFolds, &ranges)
         }
     }
 
  • Step 3: Run and commit

Run: swift test

git add Sources/OrgPresentation Sources/OrgDocument/ViewState.swift Sources/OrgEditorAppKit Tests/OrgPresentationTests Tests/OrgEditorAppKitTests/EditorTests.swift
git commit -m "Cycle folds, apply #+STARTUP visibility and indent wrapped lines"

Task 2: App model

Files:

  • Modify: Package.swift (add OrgApp), Sources/OrgIndex/IndexStore.swift (HeadingLocation gets a public init and Hashable)

  • Create: Sources/OrgApp/FileTree.swift, Sources/OrgApp/WorkspaceModel.swift, Sources/OrgApp/DocumentSession.swift

  • Test: Tests/OrgAppTests/AppTests.swift

  • Step 1: Write the failing tests

import Foundation
import OrgCore
import OrgIndex
import Testing
@testable import OrgApp

/// A temporary folder that removes itself.
final class Folder {
    let url: URL

    init() throws {
        url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
    }

    deinit {
        try? FileManager.default.removeItem(at: url)
    }

    @discardableResult
    func write(_ path: String, _ text: String) throws -> URL {
        let file = url.appendingPathComponent(path)
        try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
        try Data(text.utf8).write(to: file)
        return file.standardizedFileURL
    }
}

struct FileTreeTests {
    @Test func foldersFirstThenFiles() {
        let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map {
            FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org)
        }
        let tree = FileTreeNode.build(root: "/r", files: files)
        #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"])
        #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"])
        #expect(tree.children?[1].children == nil)
    }

    @Test func fuzzyMatching() {
        #expect(FuzzyMatch.score("wn", "work/notes.org") != nil)
        #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil)
        let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) }
        #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"])
        #expect(FuzzyMatch.rank("", files).count == 3)
    }
}

@MainActor
struct WorkspaceModelTests {
    @Test func addSearchRemoveAndPersist() async throws {
        let notes = try Folder()
        let support = try Folder()
        try notes.write("a.org", "* Groceries\nbuy apples\n")
        try notes.write("sub/b.org", "* Taxes\n")
        try notes.write("c.sync-conflict-1.org", "* Taxes copy\n")

        let model = try WorkspaceModel(directory: support.url)
        try await model.addRoot(notes.url)
        #expect(model.files.count == 3)
        #expect(model.search("apples").map(\.title) == ["Groceries"])
        #expect(model.quickOpen("bo").map(\.name) == ["b.org"])
        let root = try #require(model.rootPaths.first)
        #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"])

        let reopened = try WorkspaceModel(directory: support.url)
        #expect(reopened.roots.count == 1)
        #expect(reopened.files.count == 3)

        try reopened.removeRoot(path: root)
        #expect(reopened.roots.isEmpty)
        #expect(reopened.files.isEmpty)
        #expect(reopened.search("apples").isEmpty)
    }

    @Test func changedFilesAreReindexed() async throws {
        let notes = try Folder()
        let file = try notes.write("a.org", "* Old\n")
        let model = try WorkspaceModel(directory: nil)
        try await model.addRoot(notes.url)
        try notes.write("a.org", "* New\n")
        await model.filesChanged([file], rescan: [])
        #expect(model.search("new").count == 1)
        #expect(model.changedFile?.path == file.path)
    }
}

@MainActor
struct DocumentSessionTests {
    @Test func openShowAndReload() throws {
        let notes = try Folder()
        let file = try notes.write("a.org", "* One\n* Two\n")
        let session = DocumentSession()
        session.open(file.path)
        #expect(session.outline.map(\.title) == ["One", "Two"])

        let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale")
        session.show(location)
        #expect(session.revealRequest?.offset == 6)

        try notes.write("a.org", "intro\n* One\n* Two\n")
        session.fileChanged(file.path)
        #expect(session.outline.map(\.start) == [6, 12])
        #expect(session.diskBytes != nil)
        session.show(location)
        #expect(session.revealRequest?.offset == 12)
    }

    @Test func missingFileReportsAnError() {
        let session = DocumentSession()
        session.open("/nonexistent/a.org")
        #expect(session.lastError != nil)
        #expect(session.document == nil)
    }
}
  • Step 2: Implement
import Foundation
import OrgIndex

/// One file the workspace knows about.
public struct FileEntry: Sendable, Hashable, Identifiable {
    public let path: String
    public let root: String
    public let kind: FileKind

    public var id: String { path }
    public var name: String { (path as NSString).lastPathComponent }
    /// Path below the root, for display and agenda globs.
    public var relativePath: String {
        path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path
    }
}

/// A folder or file in the sidebar.
public struct FileTreeNode: Sendable, Hashable, Identifiable {
    public let name: String
    /// The file's or folder's absolute path.
    public let path: String
    public let file: FileEntry?
    /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
    public let children: [FileTreeNode]?

    public var id: String { path }

    /// Folders first, then files, each by name. Conflict copies are listed with their files.
    public static func build(root: String, files: [FileEntry]) -> FileTreeNode {
        final class Folder {
            var folders: [String: Folder] = [:]
            var files: [FileEntry] = []
        }
        let top = Folder()
        for file in files where file.root == root {
            var folder = top
            for component in file.relativePath.split(separator: "/").dropLast() {
                let name = String(component)
                if folder.folders[name] == nil { folder.folders[name] = Folder() }
                folder = folder.folders[name]!
            }
            folder.files.append(file)
        }
        func node(_ folder: Folder, name: String, path: String) -> FileTreeNode {
            let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
                node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
            }
            let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map {
                FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
            }
            return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
        }
        return node(top, name: (root as NSString).lastPathComponent, path: root)
    }
}

/// Subsequence matching for quick open: every query character must appear in order. Matches
/// at word starts and runs of consecutive characters score higher; shorter names win ties.
public enum FuzzyMatch {
    public static func score(_ query: String, _ candidate: String) -> Int? {
        let query = Array(query.lowercased().filter { !$0.isWhitespace })
        let text = Array(candidate.lowercased())
        guard !query.isEmpty else { return 0 }
        var score = 0
        var q = 0
        var previousMatch = -2
        for (i, c) in text.enumerated() where q < query.count && c == query[q] {
            score += 1
            if i == previousMatch + 1 { score += 5 }
            if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 }
            previousMatch = i
            q += 1
        }
        guard q == query.count else { return nil }
        return score * 100 - text.count
    }

    /// Entries ranked by `score` against their relative path, best first.
    public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
        files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
            .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath }
            .prefix(limit)
            .map(\.0)
    }
}
import Foundation
import Observation
import OrgIndex
import OrgWorkspace

/// The folders the user added, the index over them, and the queries the window runs.
@MainActor
@Observable
public final class WorkspaceModel {
    public private(set) var roots: [WorkspaceRoot] = []
    public private(set) var files: [FileEntry] = []
    public private(set) var isIndexing = false
    /// The last error worth showing, such as a folder that can no longer be found.
    public var lastError: String?
    /// Bumped when an indexed file changes on disk, with its path.
    public private(set) var changedFile: (path: String, version: Int)?

    public let index: IndexStore
    public let settings: IndexSettings
    private let rootsFile: URL
    /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
    private var resolvedRoots: [String: URL] = [:]
    @ObservationIgnored private var watcher: AnyObject?
    private var changeVersion = 0

    /// `directory` holds the index and the list of roots; nil keeps both in memory.
    public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
        self.settings = settings
        if let directory {
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
            index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path)
            rootsFile = directory.appendingPathComponent("roots.json")
        } else {
            index = try IndexStore()
            rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json")
        }
        if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) {
            roots = saved
        }
        resolveRoots()
        try reloadFiles()
    }

    /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
    public static var defaultDirectory: URL {
        if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] {
            return URL(fileURLWithPath: override)
        }
        return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
    }

    // MARK: - Roots

    public func addRoot(_ url: URL) async throws {
        let root = try WorkspaceRoot(url: url)
        guard !roots.contains(where: { $0.path == root.path }) else { return }
        roots.append(root)
        try saveRoots()
        resolveRoots()
        await refresh()
    }

    /// Removes a root by its resolved path, as `FileEntry.root` gives it.
    public func removeRoot(path: String) throws {
        let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path)
        roots.removeAll { bookmarkPaths.contains($0.path) }
        try saveRoots()
        resolveRoots()
        var change = IndexChange()
        change.removals = files.filter { $0.root == path }.map(\.path)
        try index.apply(change)
        try reloadFiles()
    }

    private func saveRoots() throws {
        try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
    }

    /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found.
    private func resolveRoots() {
        resolvedRoots = [:]
        var refreshed = roots
        for (i, root) in roots.enumerated() {
            guard let (url, isStale) = try? root.resolve() else {
                lastError = "Can't find the folder \(root.path)."
                continue
            }
            if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh }
            resolvedRoots[url.path] = url
        }
        if refreshed != roots {
            roots = refreshed
            try? saveRoots()
        }
    }

    public func url(forRoot path: String) -> URL? { resolvedRoots[path] }

    /// Resolved root paths, sorted.
    public var rootPaths: [String] { resolvedRoots.keys.sorted() }

    // MARK: - Indexing

    /// Reconciles every root off the main thread, then reloads the file list.
    public func refresh() async {
        isIndexing = true
        defer { isIndexing = false }
        let reconciler = Reconciler(index: index, settings: settings)
        for url in resolvedRoots.values {
            do {
                _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
            } catch {
                lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
            }
        }
        try? reloadFiles()
    }

    private func reloadFiles() throws {
        let roots = Set(resolvedRoots.keys)
        files = try index.files().compactMap { file in
            guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil }
            return FileEntry(path: file.path, root: root, kind: file.kind)
        }
    }

    /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
    public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
        let reconciler = Reconciler(index: index, settings: settings)
        for rootURL in roots {
            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
        }
        for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
            let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
            guard !inRoot.isEmpty else { continue }
            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
        }
        try? reloadFiles()
        for path in paths {
            changeVersion += 1
            changedFile = (path.standardizedFileURL.path, changeVersion)
        }
    }

    #if os(macOS)
    /// Watches every root; changes reconcile the index and are reported through `changedFile`.
    public func startWatching() {
        let roots = Array(resolvedRoots.values)
        guard !roots.isEmpty else { return }
        let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
            var changed: [URL] = []
            var rescans: [URL] = []
            for event in events {
                switch event {
                case .changed(let url): changed.append(url)
                case .rescan(let url): rescans.append(url)
                }
            }
            Task { @MainActor [weak self] in
                guard let self else { return }
                await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:)))
            }
        }
        fsWatcher.start()
        watcher = fsWatcher
    }
    #endif

    /// The root an event path belongs to.
    private func root(containing url: URL) -> URL? {
        resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) }
    }

    /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form.
    func indexForm(_ url: URL) -> URL {
        for root in resolvedRoots.values {
            let real = root.resolvingSymlinksInPath().path
            if real != root.path, url.path.hasPrefix(real + "/") {
                return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count))
            }
        }
        return url
    }

    // MARK: - Queries

    public func tree(forRoot path: String) -> FileTreeNode {
        FileTreeNode.build(root: path, files: files)
    }

    public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
        (try? index.search(query, overlay: overlay)) ?? []
    }

    public func quickOpen(_ query: String) -> [FileEntry] {
        FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
    }
}
import Foundation
import Observation
import OrgCore
import OrgDocument
import OrgIndex
import OrgPresentation

/// The file open in a window: its state, its outline, and requests for the editor to show a
/// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`.
@MainActor
@Observable
public final class DocumentSession {
    public private(set) var path: String?
    public private(set) var document: DocumentState?
    public private(set) var outline: [OutlineEntry] = []
    /// Bumped to ask the editor to reveal an offset.
    public private(set) var revealRequest: (offset: Int, id: Int)?
    /// New bytes from disk for the open file, for the editor to merge or reload.
    public private(set) var diskBytes: (bytes: [UInt8], id: Int)?
    public var lastError: String?

    private let fileSystem: FileSystem
    private var requestID = 0

    public init(fileSystem: FileSystem = CoordinatedFileSystem()) {
        self.fileSystem = fileSystem
    }

    public func open(_ path: String) {
        do {
            guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
                lastError = "\((path as NSString).lastPathComponent) no longer exists."
                return
            }
            let document = DocumentState(bytes: bytes)
            self.path = path
            self.document = document
            outline = Presentation.outline(document.tree)
            revealRequest = nil
            diskBytes = nil
        } catch {
            lastError = "Can't open \(path): \(error.localizedDescription)"
        }
    }

    /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
    /// are used only if the file is unchanged since it was indexed; otherwise the heading is
    /// found again by position and title.
    public func show(_ location: HeadingLocation) {
        if path != location.path { open(location.path) }
        guard let document else { return }
        let hash = FileRecord.hash(document.mergeBase)
        let offset: Int
        if hash == location.contentHash {
            offset = location.start
        } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
            offset = outline[location.ordinal].start
        } else if let entry = outline.first(where: { $0.title == location.title }) {
            offset = entry.start
        } else {
            return
        }
        reveal(offset)
    }

    public func reveal(_ offset: Int) {
        requestID += 1
        revealRequest = (offset, requestID)
    }

    /// Called when the workspace sees the open file change on disk.
    public func fileChanged(_ path: String) {
        guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
        guard bytes != document?.mergeBase else { return }
        var updated = document ?? DocumentState(bytes: bytes)
        _ = updated.diskChanged(to: bytes)
        document = updated
        outline = Presentation.outline(updated.tree)
        requestID += 1
        diskBytes = (bytes, requestID)
    }
}
diff --git a/Sources/OrgIndex/IndexStore.swift b/Sources/OrgIndex/IndexStore.swift
index 517c833..556ca69 100644
--- a/Sources/OrgIndex/IndexStore.swift
+++ b/Sources/OrgIndex/IndexStore.swift
@@ -12,12 +12,20 @@ public struct FileState: Sendable, Equatable {
 
 /// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to,
 /// so a caller can tell whether they still apply to an open buffer.
-public struct HeadingLocation: Sendable, Equatable {
+public struct HeadingLocation: Sendable, Hashable {
     public let path: String
     public let ordinal: Int
     public let title: String
     public let start: Int
     public let contentHash: String
+
+    public init(path: String, ordinal: Int, title: String, start: Int, contentHash: String) {
+        self.path = path
+        self.ordinal = ordinal
+        self.title = title
+        self.start = start
+        self.contentHash = contentHash
+    }
 }
 
 /// One reconciliation's worth of changes, applied in a single transaction.
  • Step 3: Run and commit

Run: swift test --filter OrgAppTests

git add Package.swift Sources/OrgIndex/IndexStore.swift Sources/OrgApp Tests/OrgAppTests
git commit -m "Add workspace and document session models"

Task 3: The app

Files:

  • Modify: Package.swift (add the Orgstar executable), README.md

  • Create: Sources/Orgstar/OrgstarApp.swift, ContentView.swift, EditorView.swift, DevelopmentHooks.swift, scripts/build-app.sh

  • Step 1: Implement

import OrgApp
import SwiftUI

/// Window-level requests the menu bar makes.
@MainActor
@Observable
final class AppCommands {
    var showingQuickOpen = false
    var showingFolderPicker = false
    var focusSearch = false
}

@main
struct OrgstarApp: App {
    @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
    @State private var commands = AppCommands()

    var body: some Scene {
        Window("Orgstar", id: "main") {
            switch workspace {
            case .success(let model):
                ContentView(workspace: model, commands: commands)
            case .failure(let error):
                ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
            }
        }
        .defaultSize(width: 1280, height: 860)
        .commands {
            CommandGroup(after: .newItem) {
                Button("Add Folder…") { commands.showingFolderPicker = true }
                    .keyboardShortcut("o", modifiers: [.command, .shift])
                Button("Quick Open…") { commands.showingQuickOpen = true }
                    .keyboardShortcut("p")
            }
            CommandGroup(after: .textEditing) {
                Button("Search Notes") { commands.focusSearch = true }
                    .keyboardShortcut("f", modifiers: [.command, .shift])
            }
        }
    }
}
import OrgApp
import OrgIndex
import SwiftUI

struct ContentView: View {
    let workspace: WorkspaceModel
    @Bindable var commands: AppCommands
    @State private var session = DocumentSession()
    @State private var selectedFile: String?
    @State private var query = ""
    @FocusState private var searchFocused: Bool

    var body: some View {
        NavigationSplitView {
            Sidebar(workspace: workspace, selection: $selectedFile)
                .navigationSplitViewColumnWidth(min: 200, ideal: 260)
        } content: {
            Group {
                if query.isEmpty {
                    OutlineColumn(session: session)
                } else {
                    SearchResults(results: workspace.search(query)) { location in
                        selectedFile = location.path
                        session.show(location)
                    }
                }
            }
            .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
        } detail: {
            if session.document != nil {
                EditorView(session: session)
            } else {
                ContentUnavailableView(
                    workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
                    systemImage: "doc.text",
                    description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
                )
            }
        }
        .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
        .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
        .searchFocused($searchFocused)
        .toolbar {
            if workspace.isIndexing { ProgressView().controlSize(.small) }
        }
        .onChange(of: selectedFile) { _, path in
            if let path, path != session.path { session.open(path) }
        }
        .onChange(of: workspace.changedFile?.version) {
            if let path = workspace.changedFile?.path { session.fileChanged(path) }
        }
        .onChange(of: commands.focusSearch) { _, focus in
            if focus {
                searchFocused = true
                commands.focusSearch = false
            }
        }
        .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
            guard case .success(let url) = result else { return }
            Task {
                do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
            }
        }
        .sheet(isPresented: $commands.showingQuickOpen) {
            QuickOpenView(workspace: workspace) { entry in
                selectedFile = entry.path
                commands.showingQuickOpen = false
            }
        }
        .alert("Orgstar", isPresented: errorShown) {
            Button("OK") {
                workspace.lastError = nil
                session.lastError = nil
            }
        } message: {
            Text(workspace.lastError ?? session.lastError ?? "")
        }
        .task {
            DevelopmentHooks.scheduleSnapshot()
            if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
            await workspace.refresh()
            workspace.startWatching()
        }
    }

    private var errorShown: Binding<Bool> {
        Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
    }
}

struct Sidebar: View {
    let workspace: WorkspaceModel
    @Binding var selection: String?

    var body: some View {
        List(selection: $selection) {
            ForEach(workspace.rootPaths, id: \.self) { root in
                Section((root as NSString).lastPathComponent) {
                    OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
                        Label(node.name, systemImage: icon(node))
                            .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
                            .tag(node.file?.path)
                    }
                }
                .contextMenu {
                    Button("Remove Folder") { try? workspace.removeRoot(path: root) }
                }
            }
        }
        .listStyle(.sidebar)
    }

    private func icon(_ node: FileTreeNode) -> String {
        guard let file = node.file else { return "folder" }
        switch file.kind {
        case .org: return "doc.text"
        case .archive: return "archivebox"
        case .conflict: return "exclamationmark.triangle"
        }
    }
}

struct OutlineColumn: View {
    let session: DocumentSession

    var body: some View {
        List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
            Button {
                session.reveal(entry.start)
            } label: {
                Text(entry.title.isEmpty ? "(untitled)" : entry.title)
                    .lineLimit(1)
                    .padding(.leading, CGFloat(entry.level - 1) * 12)
            }
            .buttonStyle(.plain)
        }
        .overlay {
            if session.document != nil, session.outline.isEmpty {
                ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
            }
        }
    }
}

struct SearchResults: View {
    let results: [HeadingLocation]
    let open: (HeadingLocation) -> Void

    var body: some View {
        List(results, id: \.self) { location in
            Button {
                open(location)
            } label: {
                VStack(alignment: .leading, spacing: 2) {
                    Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
                    Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
                }
            }
            .buttonStyle(.plain)
        }
        .overlay {
            if results.isEmpty { ContentUnavailableView.search }
        }
    }
}

struct QuickOpenView: View {
    let workspace: WorkspaceModel
    let open: (FileEntry) -> Void
    @State private var query = ""
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        let matches = workspace.quickOpen(query)
        VStack(spacing: 0) {
            TextField("Open file", text: $query)
                .textFieldStyle(.plain)
                .font(.title3)
                .padding(12)
                .onSubmit { if let first = matches.first { open(first) } }
            Divider()
            List(matches) { entry in
                Button {
                    open(entry)
                } label: {
                    VStack(alignment: .leading, spacing: 2) {
                        Text(entry.name)
                        Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
                    }
                }
                .buttonStyle(.plain)
            }
        }
        .frame(width: 520, height: 360)
        .onExitCommand { dismiss() }
    }
}
import AppKit
import OrgApp
import OrgEditorAppKit
import SwiftUI

/// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal
/// requests and changes on disk go to the existing one, so folds survive a reload.
struct EditorView: NSViewRepresentable {
    let session: DocumentSession

    final class Coordinator {
        var editor: OrgEditor?
        var path: String?
        var revealID = 0
        var diskID = 0
    }

    func makeCoordinator() -> Coordinator { Coordinator() }

    func makeNSView(context: Context) -> NSView {
        NSView()
    }

    func updateNSView(_ container: NSView, context: Context) {
        let coordinator = context.coordinator
        if coordinator.path != session.path {
            coordinator.path = session.path
            container.subviews.forEach { $0.removeFromSuperview() }
            coordinator.editor = nil
            if let document = session.document {
                let editor = OrgEditor(document: document, frame: container.bounds, editable: false)
                let scroll = editor.makeScrollView()
                scroll.frame = container.bounds
                scroll.autoresizingMask = [.width, .height]
                container.addSubview(scroll)
                container.window?.makeFirstResponder(editor.textView)
                coordinator.editor = editor
            }
        }
        if let request = session.revealRequest, request.id != coordinator.revealID {
            coordinator.revealID = request.id
            coordinator.editor?.reveal(request.offset)
        }
        if let disk = session.diskBytes, disk.id != coordinator.diskID {
            coordinator.diskID = disk.id
            coordinator.editor?.diskChanged(to: disk.bytes)
        }
    }
}
import AppKit

/// Switches for checking the app from scripts, read from the environment:
/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
/// - `ORGSTAR_OPEN`: a file to select at launch.
/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
///   window to this PNG path and quit. Rendering from inside the app needs no screen-recording
///   permission.
enum DevelopmentHooks {
    static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }

    @MainActor
    static func scheduleSnapshot() {
        let environment = ProcessInfo.processInfo.environment
        guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
        let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview,
               let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
                view.cacheDisplay(in: view.bounds, to: bitmap)
                try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
            }
            NSApp.terminate(nil)
        }
    }
}
#!/bin/sh
# Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use.
# Usage: scripts/build-app.sh [version]
set -eu

cd "$(dirname "$0")/.."
version="${1:-0.1.0}"
app=".build/app/Orgstar.app"

swift build -c release --product Orgstar
binary="$(swift build -c release --show-bin-path)/Orgstar"

rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$binary" "$app/Contents/MacOS/Orgstar"

cat > "$app/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleIdentifier</key>
    <string>sh.krz.orgstar</string>
    <key>CFBundleName</key>
    <string>Orgstar</string>
    <key>CFBundleDisplayName</key>
    <string>Orgstar</string>
    <key>CFBundleExecutable</key>
    <string>Orgstar</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>${version}</string>
    <key>CFBundleVersion</key>
    <string>${version}</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>LSApplicationCategoryType</key>
    <string>public.app-category.productivity</string>
    <key>NSHighResolutionCapable</key>
    <true/>
    <key>NSPrincipalClass</key>
    <string>NSApplication</string>
</dict>
</plist>
EOF

codesign --force --sign - "$app"
echo "$app"
  • Step 2: Build, run and snapshot

Run: scripts/build-app.sh, then with a seeded data directory: ORGSTAR_DATA_DIR=<dir> ORGSTAR_OPEN=<file.org> ORGSTAR_SNAPSHOT=/tmp/shot.png .build/app/Orgstar.app/Contents/MacOS/Orgstar Check: outline column lists the file's headings by level; the editor shows hidden leading stars, dimmed markup, body indents, and wrapped list lines under their text; the title is the file name.

Manual: add a folder with ⇧⌘O; ⌘P opens a file; ⇧⌘F search opens results that reveal the heading; TAB and Shift-TAB fold; editing the file in Emacs reloads the view.

  • Step 3: Commit
git add Package.swift README.md Sources/Orgstar scripts
git commit -m "Add the read-only viewer app"

Task 4: Share paragraph styles

Per-line hanging indents gave every line its own NSParagraphStyle, which pushed a 1.5 MB file from 9.6x to 10.2x its size in memory, over the 10x gate. One style object per indent pair brings it back to 9.7x.

Files:

  • Modify: Sources/OrgEditorAppKit/OrgEditor.swift

  • Step 1: Implement

@@ -59,6 +59,13 @@ public final class OrgEditor: NSObject {
     /// passes reach it. Always at a line start.
     private(set) var styledUpTo = 0
     private var stylingScheduled = false
+    /// One paragraph style object per indent pair, shared by every line that uses it.
+    private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:]
+
+    private struct IndentKey: Hashable {
+        let firstLine: Int
+        let wrapped: Int
+    }
 
     /// Styled before the first render: more than a screenful.
     static let firstStyleChunk = 30_000
@@ -253,7 +260,7 @@ public final class OrgEditor: NSObject {
         for run in Presentation.indents(document.tree, in: range) {
             guard let clipped = clip(run.range, to: range) else { continue }
             guard run.firstLine == run.wrapped else {
-                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
+                storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
                 continue
             }
             // Body lines wrap under their own text: past leading blanks, and past a list bullet
@@ -263,7 +270,7 @@ public final class OrgEditor: NSObject {
                 let line = text.lineRange(for: NSRange(location: location, length: 0))
                 let hanging = Self.hangingColumns(text.substring(with: line))
                 let lineRange = NSIntersectionRange(line, clipped)
-                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange)
+                storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
                 location = NSMaxRange(line)
             }
         }
@@ -273,6 +280,14 @@ public final class OrgEditor: NSObject {
         }
     }
 
+    private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
+        let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
+        if let style = paragraphStyles[key] { return style }
+        let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
+        paragraphStyles[key] = style
+        return style
+    }
+
     private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
         let clipped = run.clamped(to: range)
         return clipped.isEmpty ? nil : NSRange(clipped)
  • Step 2: Run the gates; commit

Run: ORGSTAR_GATES=1 ORGSTAR_GATE_FILE=<large real file> swift test -c release --filter GateTests

git add Sources/OrgEditorAppKit/OrgEditor.swift
git commit -m "Share paragraph styles across lines"