krz/orgstar

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

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

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

1684 lines · 69681 bytes

   1# Read-only Viewer Implementation Plan
   2
   3> **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.
   4
   5**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`).
   6
   7**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.
   8
   9**Tech Stack:** Swift 6.2 tools, SwiftUI, AppKit, TextKit 2, Swift Testing.
  10
  11**Spec:** `docs/design.md`, "Phases" (phase 1 scope: Mac app, rendering, folding).
  12
  13## Global Constraints
  14
  15- The viewer never writes to the user's files; the editor runs with `editable: false`.
  16- `OrgApp` and `OrgPresentation` import no AppKit or SwiftUI.
  17- Decisions (user): SwiftPM executable plus a bundle script, not an Xcode project; SwiftUI shell with the AppKit editor. Bundle identifier `sh.krz.orgstar`.
  18
  19## Development hooks
  20
  21`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.
  22
  23## File structure
  24
  25| File | Responsibility |
  26| --- | --- |
  27| `Sources/OrgPresentation/Folding.swift` | `FoldState`, `GlobalVisibility`, `OutlineEntry`, cycling, `#+STARTUP` |
  28| `Sources/OrgPresentation/Presentation.swift` | `hiddenRanges` with body folds |
  29| `Sources/OrgDocument/ViewState.swift` | `bodyFolds` mapped and pruned with edits |
  30| `Sources/OrgEditorAppKit/OrgEditor.swift` | Cycling, reveal, reload from disk, read-only mode, hanging indents for wrapped lines |
  31| `Sources/OrgApp/FileTree.swift` | `FileEntry`, `FileTreeNode`, `FuzzyMatch` |
  32| `Sources/OrgApp/WorkspaceModel.swift` | Roots, index, reconciliation, watching, queries |
  33| `Sources/OrgApp/DocumentSession.swift` | Open file, outline, reveal and reload requests |
  34| `Sources/Orgstar/*.swift` | The SwiftUI app, editor bridge and development hooks |
  35| `scripts/build-app.sh` | `Orgstar.app` with `Info.plist`, signed ad hoc |
  36
  37---
  38
  39### Task 1: Folding
  40
  41**Files:**
  42- Create: `Sources/OrgPresentation/Folding.swift`
  43- Modify: `Sources/OrgPresentation/Presentation.swift`, `Sources/OrgDocument/ViewState.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`
  44- Test: `Tests/OrgPresentationTests/PresentationTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift`
  45
  46**Interfaces:**
  47- 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(_:)`.
  48
  49- [ ] **Step 1: Write the failing tests**
  50
  51```diff
  52diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift
  53index 494399a..cdf9b2d 100644
  54--- a/Tests/OrgEditorAppKitTests/EditorTests.swift
  55+++ b/Tests/OrgEditorAppKitTests/EditorTests.swift
  56@@ -1,6 +1,7 @@
  57 import AppKit
  58 import OrgCore
  59 import OrgDocument
  60+import OrgPresentation
  61 import Testing
  62 @testable import OrgEditorAppKit
  63 
  64@@ -77,16 +78,45 @@ struct FoldingTests {
  65         let h = Harness(outline)
  66         #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
  67         h.caret(at: 1)
  68-        #expect(h.editor.toggleFoldAtCaret())
  69+        #expect(h.editor.cycleAtCaret())
  70         #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
  71-        h.editor.toggleFoldAtCaret()
  72+        h.editor.cycleAtCaret()
  73+        #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"])
  74+        h.editor.cycleAtCaret()
  75         #expect(h.visibleLines.count == 6)
  76     }
  77 
  78+    @Test func globalCycleAndStartup() {
  79+        let h = Harness(outline)
  80+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
  81+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
  82+        #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
  83+        h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
  84+        #expect(h.visibleLines.count == 6)
  85+        #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
  86+    }
  87+
  88+    @Test func revealUnfoldsWhatHidesTheTarget() {
  89+        let h = Harness("#+STARTUP: overview\n" + outline)
  90+        let target = h.offset(of: "more")
  91+        h.editor.reveal(target)
  92+        #expect(h.visibleLines.contains("more\n"))
  93+        #expect(h.caret == target)
  94+    }
  95+
  96+    @Test func readOnlyAndReloadKeepFolds() {
  97+        let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false)
  98+        #expect(!editor.textView.isEditable)
  99+        editor.setFolds(FoldState(folds: [19]))
 100+        editor.diskChanged(to: Array(("new line\n" + outline).utf8))
 101+        #expect(editor.document.text == "new line\n" + outline)
 102+        #expect(editor.foldState.folds == [28])
 103+    }
 104+
 105     @Test func tabFoldsOnlyOnHeadings() {
 106         let h = Harness(outline)
 107         h.caret(at: 5)
 108-        #expect(!h.editor.toggleFoldAtCaret())
 109+        #expect(!h.editor.cycleAtCaret())
 110         h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
 111         #expect(h.visibleLines == ["* a\n", "* c\n"])
 112     }
 113@@ -94,7 +124,7 @@ struct FoldingTests {
 114     @Test func caretMovesOverFoldedText() {
 115         let h = Harness(outline)
 116         h.caret(at: 1)
 117-        h.editor.toggleFoldAtCaret()
 118+        h.editor.cycleAtCaret()
 119         h.caret(at: 0)
 120         h.textView.moveDown(nil)
 121         #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
 122@@ -108,7 +138,7 @@ struct FoldingTests {
 123     @Test func copyingAcrossAFoldIncludesHiddenText() {
 124         let h = Harness(outline)
 125         h.caret(at: 1)
 126-        h.editor.toggleFoldAtCaret()
 127+        h.editor.cycleAtCaret()
 128         h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
 129         let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
 130         defer { pasteboard.releaseGlobally() }
 131@@ -119,7 +149,7 @@ struct FoldingTests {
 132     @Test func foldsFollowEdits() {
 133         let h = Harness(outline)
 134         h.caret(at: h.offset(of: "* c") + 1)
 135-        h.editor.toggleFoldAtCaret()
 136+        h.editor.cycleAtCaret()
 137         h.caret(at: 0)
 138         h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
 139         #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
 140@@ -177,7 +207,7 @@ struct EditingTests {
 141     @Test func accessibilityReadsTheWholeText() {
 142         let h = Harness(outline)
 143         h.caret(at: 1)
 144-        h.editor.toggleFoldAtCaret()
 145+        h.editor.cycleAtCaret()
 146         #expect(h.textView.accessibilityValue() as? String == outline)
 147         h.textView.setSelectedRange(NSRange(location: 0, length: 3))
 148         #expect(h.textView.accessibilitySelectedText() == "* a")
 149@@ -280,3 +310,13 @@ struct InitialStylingTests {
 150         #expect(h.editor.styledUpTo == before + 3)
 151     }
 152 }
 153+
 154+struct HangingIndentTests {
 155+    @Test(arguments: [
 156+        ("plain", 0), ("  indented", 2), ("- item", 2), ("  - [X] done", 8), ("12. step", 4),
 157+        ("* heading-like", 0), ("  * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
 158+    ])
 159+    func columns(line: String, expected: Int) {
 160+        #expect(OrgEditor.hangingColumns(line) == expected)
 161+    }
 162+}
 163diff --git a/Tests/OrgPresentationTests/PresentationTests.swift b/Tests/OrgPresentationTests/PresentationTests.swift
 164index 6e1ca3d..7007d92 100644
 165--- a/Tests/OrgPresentationTests/PresentationTests.swift
 166+++ b/Tests/OrgPresentationTests/PresentationTests.swift
 167@@ -76,3 +76,47 @@ struct FoldTests {
 168         #expect(Presentation.headings(tree) == [0, 9, 19])
 169     }
 170 }
 171+
 172+struct CyclingTests {
 173+    let text = "* a\nbody\n** b\nmore\n*** c\ndeep\n* d\n"
 174+    var tree: OrgTree { OrgParser.parse(text) }
 175+
 176+    @Test func outline() {
 177+        let entries = Presentation.outline(tree)
 178+        #expect(entries.map(\.title) == ["a", "b", "c", "d"])
 179+        #expect(entries.map(\.parent) == [nil, 0, 1, nil])
 180+        #expect(entries.map(\.isTopLevel) == [true, false, false, true])
 181+    }
 182+
 183+    @Test func localCycle() {
 184+        var state = FoldState()
 185+        state = Presentation.cycle(0, in: tree, state)
 186+        #expect(state == FoldState(folds: [0]))
 187+        state = Presentation.cycle(0, in: tree, state)
 188+        #expect(state == FoldState(folds: [9]))
 189+        state = Presentation.cycle(0, in: tree, state)
 190+        #expect(state == FoldState())
 191+        #expect(Presentation.cycle(30, in: tree, FoldState(folds: [30])) == FoldState())
 192+    }
 193+
 194+    @Test func globalStates() {
 195+        #expect(Presentation.foldState(for: .overview, in: tree) == FoldState(folds: [0, 30]))
 196+        #expect(Presentation.foldState(for: .contents, in: tree) == FoldState(bodyFolds: [0, 9, 19, 30]))
 197+        #expect(Presentation.cycleGlobal(from: nil, in: tree).0 == .overview)
 198+        #expect(Presentation.cycleGlobal(from: .overview, in: tree).0 == .contents)
 199+        #expect(Presentation.cycleGlobal(from: .contents, in: tree).0 == .showAll)
 200+    }
 201+
 202+    @Test func bodyFoldsKeepChildHeadings() {
 203+        #expect(Presentation.hiddenRanges(tree, folds: [], bodyFolds: [0, 9, 19, 30]) == [4..<9, 14..<19, 25..<30])
 204+    }
 205+
 206+    @Test func startup() {
 207+        #expect(Presentation.startupFoldState(OrgParser.parse("#+STARTUP: content\n" + text)) != nil)
 208+        #expect(Presentation.startupFoldState(OrgParser.parse(text)) == nil)
 209+        let levels = OrgParser.parse("#+startup: show2levels\n" + text)
 210+        let state = Presentation.startupFoldState(levels)
 211+        #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2)
 212+        #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
 213+    }
 214+}
 215```
 216
 217- [ ] **Step 2: Implement**
 218
 219```swift
 220import OrgCore
 221
 222/// Which headings are folded: whole subtrees, or only their own bodies.
 223public struct FoldState: Sendable, Equatable {
 224    public var folds: Set<Int>
 225    public var bodyFolds: Set<Int>
 226
 227    public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
 228        self.folds = folds
 229        self.bodyFolds = bodyFolds
 230    }
 231}
 232
 233/// org's global visibility states, cycled by Shift-TAB.
 234public enum GlobalVisibility: Sendable, Equatable {
 235    /// Top-level headings only.
 236    case overview
 237    /// Every heading, no bodies.
 238    case contents
 239    case showAll
 240}
 241
 242public struct OutlineEntry: Sendable, Equatable {
 243    /// Start offset of the heading line.
 244    public let start: Int
 245    public let level: Int
 246    /// The title as written.
 247    public let title: String
 248    /// End of the heading's section.
 249    public let end: Int
 250    /// Index of the parent entry.
 251    public let parent: Int?
 252    public let isTopLevel: Bool
 253}
 254
 255extension Presentation {
 256    /// Every heading in document order.
 257    public static func outline(_ tree: OrgTree) -> [OutlineEntry] {
 258        var entries: [OutlineEntry] = []
 259        collectOutline(tree.root, parent: nil, topLevel: true, &entries)
 260        return entries
 261    }
 262
 263    private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) {
 264        for section in node.children where section.kind == .section {
 265            guard let heading = section.firstChild(.heading) else { continue }
 266            let index = entries.count
 267            entries.append(OutlineEntry(
 268                start: heading.range.lowerBound,
 269                level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1,
 270                title: heading.firstChild(.title)?.text ?? "",
 271                end: section.range.upperBound,
 272                parent: parent,
 273                isTopLevel: topLevel
 274            ))
 275            collectOutline(section, parent: index, topLevel: false, &entries)
 276        }
 277    }
 278
 279    /// TAB on a heading: folded, then its children, then the whole subtree, then folded again.
 280    /// A heading without children goes straight from folded to unfolded.
 281    public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
 282        let outline = outline(tree)
 283        guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
 284        let entry = outline[index]
 285        let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start)
 286        let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start)
 287        var next = state
 288        func unfoldSubtree() {
 289            next.folds.subtract([heading] + descendants)
 290            next.bodyFolds.subtract([heading] + descendants)
 291        }
 292        if state.folds.contains(heading) {
 293            unfoldSubtree()
 294            next.folds.formUnion(children)
 295        } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) {
 296            unfoldSubtree()
 297        } else {
 298            unfoldSubtree()
 299            next.folds.insert(heading)
 300        }
 301        return next
 302    }
 303
 304    /// Shift-TAB: overview, contents, show all, and around.
 305    public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) {
 306        let next: GlobalVisibility = switch current {
 307        case .overview: .contents
 308        case .contents: .showAll
 309        case .showAll, nil: .overview
 310        }
 311        return (next, foldState(for: next, in: tree))
 312    }
 313
 314    public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState {
 315        let outline = outline(tree)
 316        switch visibility {
 317        case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start)))
 318        case .contents: return FoldState(bodyFolds: Set(outline.map(\.start)))
 319        case .showAll: return FoldState()
 320        }
 321    }
 322
 323    /// Headings of levels 1 through `levels` visible, no bodies.
 324    public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState {
 325        let outline = outline(tree)
 326        return FoldState(
 327            folds: Set(outline.filter { $0.level == levels }.map(\.start)),
 328            bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start))
 329        )
 330    }
 331
 332    /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none.
 333    /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`...
 334    public static func startupFoldState(_ tree: OrgTree) -> FoldState? {
 335        var result: FoldState?
 336        for option in startupOptions(tree) {
 337            switch option.lowercased() {
 338            case "overview", "fold": result = foldState(for: .overview, in: tree)
 339            case "content": result = foldState(for: .contents, in: tree)
 340            case "showall", "nofold", "showeverything": result = FoldState()
 341            case let word where word.hasPrefix("show") && word.hasSuffix("levels"):
 342                if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 {
 343                    result = foldState(showingLevels: levels, in: tree)
 344                }
 345            default: break
 346            }
 347        }
 348        return result
 349    }
 350
 351    /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside
 352    /// blocks and paragraphs is never read as a setting.
 353    public static func startupOptions(_ tree: OrgTree) -> [String] {
 354        var options: [String] = []
 355        func visit(_ node: GreenNode) {
 356            for case .node(let child) in node.children {
 357                switch child.kind {
 358                case .keyword:
 359                    let text = child.text
 360                    if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"),
 361                       let colon = text.firstIndex(of: ":") {
 362                        options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init)
 363                    }
 364                case .document, .zerothSection, .section, .drawer:
 365                    visit(child)
 366                default:
 367                    break
 368                }
 369            }
 370        }
 371        visit(tree.green)
 372        return options
 373    }
 374}
 375```
 376
 377```diff
 378diff --git a/Sources/OrgDocument/ViewState.swift b/Sources/OrgDocument/ViewState.swift
 379index 9cdf35a..6f84444 100644
 380--- a/Sources/OrgDocument/ViewState.swift
 381+++ b/Sources/OrgDocument/ViewState.swift
 382@@ -3,12 +3,15 @@ import OrgCore
 383 /// Per-window state for one document: selection and folds, in UTF-16 offsets.
 384 public struct ViewState: Sendable, Equatable {
 385     public var selection: [Range<Int>]
 386-    /// Start offsets of folded headings.
 387+    /// Start offsets of headings whose whole subtree is folded.
 388     public var folds: Set<Int>
 389+    /// Start offsets of headings whose own body is folded; their child headings stay visible.
 390+    public var bodyFolds: Set<Int>
 391 
 392-    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
 393+    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
 394         self.selection = selection
 395         self.folds = folds
 396+        self.bodyFolds = bodyFolds
 397     }
 398 
 399     /// Maps through non-overlapping edits given in old coordinates.
 400@@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable {
 401         let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
 402         return ViewState(
 403             selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
 404-            folds: Set(folds.map { mapOffset($0, sorted) })
 405+            folds: Set(folds.map { mapOffset($0, sorted) }),
 406+            bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
 407         )
 408     }
 409 
 410     /// Drops folds that no longer sit at the start of a heading.
 411     public func pruned(to tree: OrgTree) -> ViewState {
 412-        guard !folds.isEmpty else { return self }
 413-        return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
 414+        guard !folds.isEmpty || !bodyFolds.isEmpty else { return self }
 415+        return ViewState(
 416+            selection: selection,
 417+            folds: folds.filter { isHeadingStart($0, tree.root) },
 418+            bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) }
 419+        )
 420     }
 421 }
 422 
 423diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift
 424index b7c3dc4..2a52c5e 100644
 425--- a/Sources/OrgEditorAppKit/OrgEditor.swift
 426+++ b/Sources/OrgEditorAppKit/OrgEditor.swift
 427@@ -65,12 +65,13 @@ public final class OrgEditor: NSObject {
 428     /// Styled per run-loop turn afterwards.
 429     static let styleChunk = 200_000
 430 
 431-    public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
 432+    public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
 433         self.document = document
 434         textView = NSTextView(usingTextLayoutManager: true)
 435         super.init()
 436         textView.frame = frame
 437         textView.isRichText = false
 438+        textView.isEditable = editable
 439         textView.allowsUndo = true
 440         textView.isAutomaticQuoteSubstitutionEnabled = false
 441         textView.isAutomaticDashSubstitutionEnabled = false
 442@@ -87,6 +88,7 @@ public final class OrgEditor: NSObject {
 443         textContentStorage.delegate = self
 444 
 445         load(document.text)
 446+        if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
 447     }
 448 
 449     /// Replaces the storage's text. Setting the attributed string directly is about 100x
 450@@ -153,37 +155,77 @@ public final class OrgEditor: NSObject {
 451         if document.text != before {
 452             let edits = lineEdits(from: before, to: document.text)
 453             load(document.text)
 454-            setFolds(view.mapped(through: edits).folds)
 455+            let mapped = view.mapped(through: edits)
 456+            setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
 457         }
 458         return outcome
 459     }
 460 
 461+    // MARK: - Disk
 462+
 463+    /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
 464+    /// its headings.
 465+    public func diskChanged(to bytes: [UInt8]) {
 466+        let edits: [TextEdit]
 467+        switch document.diskChanged(to: bytes) {
 468+        case .reloaded(let changes), .merged(let changes): edits = changes
 469+        case .unchanged, .conflict: return
 470+        }
 471+        let mapped = view.mapped(through: edits)
 472+        load(document.text)
 473+        setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
 474+    }
 475+
 476     // MARK: - Folding
 477 
 478-    public func setFolds(_ folds: Set<Int>) {
 479-        view.folds = folds
 480+    public var foldState: FoldState {
 481+        FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
 482+    }
 483+
 484+    private var globalVisibility: GlobalVisibility?
 485+
 486+    public func setFolds(_ state: FoldState) {
 487+        view.folds = state.folds
 488+        view.bodyFolds = state.bodyFolds
 489         view = view.pruned(to: document.tree)
 490-        folded.set(view.folds)
 491-        hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
 492+        updateHidden()
 493         textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 494         textView.needsDisplay = true
 495         let caret = textView.selectedRange().location
 496         if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
 497     }
 498 
 499-    /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
 500+    private func updateHidden() {
 501+        folded.set(view.folds.union(view.bodyFolds))
 502+        hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
 503+    }
 504+
 505+    /// TAB on a heading: folded, children, subtree. Returns false off a heading.
 506     @discardableResult
 507-    public func toggleFoldAtCaret() -> Bool {
 508+    public func cycleAtCaret() -> Bool {
 509         guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
 510-        var folds = view.folds
 511-        if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
 512-        setFolds(folds)
 513+        setFolds(Presentation.cycle(heading, in: document.tree, foldState))
 514         return true
 515     }
 516 
 517-    /// Folds every heading, or unfolds everything if anything is folded.
 518-    public func toggleAllFolds() {
 519-        setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
 520+    /// Shift-TAB: overview, contents, show all.
 521+    public func cycleGlobal() {
 522+        let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
 523+        globalVisibility = visibility
 524+        setFolds(state)
 525+    }
 526+
 527+    /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
 528+    public func reveal(_ offset: Int) {
 529+        let target = max(0, min(offset, utf16Length))
 530+        var state = foldState
 531+        for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
 532+            state.folds.remove(entry.start)
 533+            state.bodyFolds.remove(entry.start)
 534+        }
 535+        setFolds(state)
 536+        setCaret(target)
 537+        textView.scrollRangeToVisible(NSRange(location: target, length: 0))
 538     }
 539 
 540     private func setCaret(_ offset: Int) {
 541@@ -207,9 +249,23 @@ public final class OrgEditor: NSObject {
 542         guard !range.isEmpty else { return }
 543         let whole = NSRange(range)
 544         storage.setAttributes(theme.base, range: whole)
 545+        let text = storage.string as NSString
 546         for run in Presentation.indents(document.tree, in: range) {
 547             guard let clipped = clip(run.range, to: range) else { continue }
 548-            storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
 549+            guard run.firstLine == run.wrapped else {
 550+                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
 551+                continue
 552+            }
 553+            // Body lines wrap under their own text: past leading blanks, and past a list bullet
 554+            // and checkbox, as adaptive-wrap does in Emacs.
 555+            var location = clipped.location
 556+            while location < NSMaxRange(clipped) {
 557+                let line = text.lineRange(for: NSRange(location: location, length: 0))
 558+                let hanging = Self.hangingColumns(text.substring(with: line))
 559+                let lineRange = NSIntersectionRange(line, clipped)
 560+                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange)
 561+                location = NSMaxRange(line)
 562+            }
 563         }
 564         for run in Presentation.styles(document.tree, in: range) {
 565             guard let clipped = clip(run.range, to: range) else { continue }
 566@@ -301,6 +357,34 @@ public final class OrgEditor: NSObject {
 567         onEditTiming?(clock.now - start)
 568     }
 569 
 570+    /// Columns a wrapped continuation of `line` should start past the line's own start.
 571+    nonisolated static func hangingColumns(_ line: String) -> Int {
 572+        var columns = 0
 573+        var rest = Substring(line)
 574+        while let c = rest.first, c == " " || c == "\t" {
 575+            columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
 576+            rest = rest.dropFirst()
 577+        }
 578+        // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
 579+        var bullet = 0
 580+        if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
 581+            bullet = 1
 582+        } else {
 583+            let digits = rest.prefix { $0.isASCII && $0.isNumber }
 584+            if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
 585+                bullet = digits.count + 1
 586+            }
 587+        }
 588+        guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
 589+        columns += bullet + 1
 590+        let afterBullet = rest.dropFirst(bullet + 1)
 591+        if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
 592+           afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
 593+            columns += 4
 594+        }
 595+        return columns
 596+    }
 597+
 598     /// Whether a heading line touches `start..<end` or starts right after it, where joining
 599     /// lines could take it away.
 600     static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
 601@@ -332,9 +416,9 @@ public final class OrgEditor: NSObject {
 602 extension OrgEditor: NSTextViewDelegate {
 603     public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
 604         switch selector {
 605-        case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
 606+        case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
 607         case #selector(NSResponder.insertBacktab(_:)):
 608-            toggleAllFolds()
 609+            cycleGlobal()
 610             return true
 611         default: return false
 612         }
 613diff --git a/Sources/OrgPresentation/Presentation.swift b/Sources/OrgPresentation/Presentation.swift
 614index 504e0b5..1813443 100644
 615--- a/Sources/OrgPresentation/Presentation.swift
 616+++ b/Sources/OrgPresentation/Presentation.swift
 617@@ -130,25 +130,31 @@ public enum Presentation {
 618         }
 619     }
 620 
 621-    /// Ranges to hide for folded headings, given by their start offsets: from the end of the
 622-    /// heading line to the end of its section. Sorted, without nested duplicates.
 623-    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
 624-        guard !folds.isEmpty else { return [] }
 625+    /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
 626+    /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
 627+    /// Sorted, without nested duplicates.
 628+    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
 629+        guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
 630         var ranges: [Range<Int>] = []
 631-        collectHidden(tree.root, folds, &ranges)
 632+        collectHidden(tree.root, folds, bodyFolds, &ranges)
 633         return ranges
 634     }
 635 
 636-    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
 637+    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
 638         for section in node.children where section.kind == .section {
 639-            guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
 640-            if folds.contains(heading.range.lowerBound) {
 641+            guard let heading = section.firstChild(.heading) else { continue }
 642+            let start = heading.range.lowerBound
 643+            if folds.contains(start) {
 644                 if heading.range.upperBound < section.range.upperBound {
 645                     ranges.append(heading.range.upperBound..<section.range.upperBound)
 646                 }
 647-            } else {
 648-                collectHidden(section, folds, &ranges)
 649+                continue
 650             }
 651+            if bodyFolds.contains(start) {
 652+                let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
 653+                if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) }
 654+            }
 655+            collectHidden(section, folds, bodyFolds, &ranges)
 656         }
 657     }
 658 
 659```
 660
 661- [ ] **Step 3: Run and commit**
 662
 663Run: `swift test`
 664
 665```bash
 666git add Sources/OrgPresentation Sources/OrgDocument/ViewState.swift Sources/OrgEditorAppKit Tests/OrgPresentationTests Tests/OrgEditorAppKitTests/EditorTests.swift
 667git commit -m "Cycle folds, apply #+STARTUP visibility and indent wrapped lines"
 668```
 669
 670---
 671
 672### Task 2: App model
 673
 674**Files:**
 675- Modify: `Package.swift` (add `OrgApp`), `Sources/OrgIndex/IndexStore.swift` (`HeadingLocation` gets a public init and `Hashable`)
 676- Create: `Sources/OrgApp/FileTree.swift`, `Sources/OrgApp/WorkspaceModel.swift`, `Sources/OrgApp/DocumentSession.swift`
 677- Test: `Tests/OrgAppTests/AppTests.swift`
 678
 679- [ ] **Step 1: Write the failing tests**
 680
 681```swift
 682import Foundation
 683import OrgCore
 684import OrgIndex
 685import Testing
 686@testable import OrgApp
 687
 688/// A temporary folder that removes itself.
 689final class Folder {
 690    let url: URL
 691
 692    init() throws {
 693        url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
 694        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
 695    }
 696
 697    deinit {
 698        try? FileManager.default.removeItem(at: url)
 699    }
 700
 701    @discardableResult
 702    func write(_ path: String, _ text: String) throws -> URL {
 703        let file = url.appendingPathComponent(path)
 704        try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
 705        try Data(text.utf8).write(to: file)
 706        return file.standardizedFileURL
 707    }
 708}
 709
 710struct FileTreeTests {
 711    @Test func foldersFirstThenFiles() {
 712        let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map {
 713            FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org)
 714        }
 715        let tree = FileTreeNode.build(root: "/r", files: files)
 716        #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"])
 717        #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"])
 718        #expect(tree.children?[1].children == nil)
 719    }
 720
 721    @Test func fuzzyMatching() {
 722        #expect(FuzzyMatch.score("wn", "work/notes.org") != nil)
 723        #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil)
 724        let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) }
 725        #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"])
 726        #expect(FuzzyMatch.rank("", files).count == 3)
 727    }
 728}
 729
 730@MainActor
 731struct WorkspaceModelTests {
 732    @Test func addSearchRemoveAndPersist() async throws {
 733        let notes = try Folder()
 734        let support = try Folder()
 735        try notes.write("a.org", "* Groceries\nbuy apples\n")
 736        try notes.write("sub/b.org", "* Taxes\n")
 737        try notes.write("c.sync-conflict-1.org", "* Taxes copy\n")
 738
 739        let model = try WorkspaceModel(directory: support.url)
 740        try await model.addRoot(notes.url)
 741        #expect(model.files.count == 3)
 742        #expect(model.search("apples").map(\.title) == ["Groceries"])
 743        #expect(model.quickOpen("bo").map(\.name) == ["b.org"])
 744        let root = try #require(model.rootPaths.first)
 745        #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"])
 746
 747        let reopened = try WorkspaceModel(directory: support.url)
 748        #expect(reopened.roots.count == 1)
 749        #expect(reopened.files.count == 3)
 750
 751        try reopened.removeRoot(path: root)
 752        #expect(reopened.roots.isEmpty)
 753        #expect(reopened.files.isEmpty)
 754        #expect(reopened.search("apples").isEmpty)
 755    }
 756
 757    @Test func changedFilesAreReindexed() async throws {
 758        let notes = try Folder()
 759        let file = try notes.write("a.org", "* Old\n")
 760        let model = try WorkspaceModel(directory: nil)
 761        try await model.addRoot(notes.url)
 762        try notes.write("a.org", "* New\n")
 763        await model.filesChanged([file], rescan: [])
 764        #expect(model.search("new").count == 1)
 765        #expect(model.changedFile?.path == file.path)
 766    }
 767}
 768
 769@MainActor
 770struct DocumentSessionTests {
 771    @Test func openShowAndReload() throws {
 772        let notes = try Folder()
 773        let file = try notes.write("a.org", "* One\n* Two\n")
 774        let session = DocumentSession()
 775        session.open(file.path)
 776        #expect(session.outline.map(\.title) == ["One", "Two"])
 777
 778        let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale")
 779        session.show(location)
 780        #expect(session.revealRequest?.offset == 6)
 781
 782        try notes.write("a.org", "intro\n* One\n* Two\n")
 783        session.fileChanged(file.path)
 784        #expect(session.outline.map(\.start) == [6, 12])
 785        #expect(session.diskBytes != nil)
 786        session.show(location)
 787        #expect(session.revealRequest?.offset == 12)
 788    }
 789
 790    @Test func missingFileReportsAnError() {
 791        let session = DocumentSession()
 792        session.open("/nonexistent/a.org")
 793        #expect(session.lastError != nil)
 794        #expect(session.document == nil)
 795    }
 796}
 797```
 798
 799- [ ] **Step 2: Implement**
 800
 801```swift
 802import Foundation
 803import OrgIndex
 804
 805/// One file the workspace knows about.
 806public struct FileEntry: Sendable, Hashable, Identifiable {
 807    public let path: String
 808    public let root: String
 809    public let kind: FileKind
 810
 811    public var id: String { path }
 812    public var name: String { (path as NSString).lastPathComponent }
 813    /// Path below the root, for display and agenda globs.
 814    public var relativePath: String {
 815        path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path
 816    }
 817}
 818
 819/// A folder or file in the sidebar.
 820public struct FileTreeNode: Sendable, Hashable, Identifiable {
 821    public let name: String
 822    /// The file's or folder's absolute path.
 823    public let path: String
 824    public let file: FileEntry?
 825    /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
 826    public let children: [FileTreeNode]?
 827
 828    public var id: String { path }
 829
 830    /// Folders first, then files, each by name. Conflict copies are listed with their files.
 831    public static func build(root: String, files: [FileEntry]) -> FileTreeNode {
 832        final class Folder {
 833            var folders: [String: Folder] = [:]
 834            var files: [FileEntry] = []
 835        }
 836        let top = Folder()
 837        for file in files where file.root == root {
 838            var folder = top
 839            for component in file.relativePath.split(separator: "/").dropLast() {
 840                let name = String(component)
 841                if folder.folders[name] == nil { folder.folders[name] = Folder() }
 842                folder = folder.folders[name]!
 843            }
 844            folder.files.append(file)
 845        }
 846        func node(_ folder: Folder, name: String, path: String) -> FileTreeNode {
 847            let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
 848                node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
 849            }
 850            let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map {
 851                FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
 852            }
 853            return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
 854        }
 855        return node(top, name: (root as NSString).lastPathComponent, path: root)
 856    }
 857}
 858
 859/// Subsequence matching for quick open: every query character must appear in order. Matches
 860/// at word starts and runs of consecutive characters score higher; shorter names win ties.
 861public enum FuzzyMatch {
 862    public static func score(_ query: String, _ candidate: String) -> Int? {
 863        let query = Array(query.lowercased().filter { !$0.isWhitespace })
 864        let text = Array(candidate.lowercased())
 865        guard !query.isEmpty else { return 0 }
 866        var score = 0
 867        var q = 0
 868        var previousMatch = -2
 869        for (i, c) in text.enumerated() where q < query.count && c == query[q] {
 870            score += 1
 871            if i == previousMatch + 1 { score += 5 }
 872            if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 }
 873            previousMatch = i
 874            q += 1
 875        }
 876        guard q == query.count else { return nil }
 877        return score * 100 - text.count
 878    }
 879
 880    /// Entries ranked by `score` against their relative path, best first.
 881    public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
 882        files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
 883            .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath }
 884            .prefix(limit)
 885            .map(\.0)
 886    }
 887}
 888```
 889
 890```swift
 891import Foundation
 892import Observation
 893import OrgIndex
 894import OrgWorkspace
 895
 896/// The folders the user added, the index over them, and the queries the window runs.
 897@MainActor
 898@Observable
 899public final class WorkspaceModel {
 900    public private(set) var roots: [WorkspaceRoot] = []
 901    public private(set) var files: [FileEntry] = []
 902    public private(set) var isIndexing = false
 903    /// The last error worth showing, such as a folder that can no longer be found.
 904    public var lastError: String?
 905    /// Bumped when an indexed file changes on disk, with its path.
 906    public private(set) var changedFile: (path: String, version: Int)?
 907
 908    public let index: IndexStore
 909    public let settings: IndexSettings
 910    private let rootsFile: URL
 911    /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
 912    private var resolvedRoots: [String: URL] = [:]
 913    @ObservationIgnored private var watcher: AnyObject?
 914    private var changeVersion = 0
 915
 916    /// `directory` holds the index and the list of roots; nil keeps both in memory.
 917    public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
 918        self.settings = settings
 919        if let directory {
 920            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
 921            index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path)
 922            rootsFile = directory.appendingPathComponent("roots.json")
 923        } else {
 924            index = try IndexStore()
 925            rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json")
 926        }
 927        if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) {
 928            roots = saved
 929        }
 930        resolveRoots()
 931        try reloadFiles()
 932    }
 933
 934    /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
 935    public static var defaultDirectory: URL {
 936        if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] {
 937            return URL(fileURLWithPath: override)
 938        }
 939        return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
 940    }
 941
 942    // MARK: - Roots
 943
 944    public func addRoot(_ url: URL) async throws {
 945        let root = try WorkspaceRoot(url: url)
 946        guard !roots.contains(where: { $0.path == root.path }) else { return }
 947        roots.append(root)
 948        try saveRoots()
 949        resolveRoots()
 950        await refresh()
 951    }
 952
 953    /// Removes a root by its resolved path, as `FileEntry.root` gives it.
 954    public func removeRoot(path: String) throws {
 955        let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path)
 956        roots.removeAll { bookmarkPaths.contains($0.path) }
 957        try saveRoots()
 958        resolveRoots()
 959        var change = IndexChange()
 960        change.removals = files.filter { $0.root == path }.map(\.path)
 961        try index.apply(change)
 962        try reloadFiles()
 963    }
 964
 965    private func saveRoots() throws {
 966        try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
 967    }
 968
 969    /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found.
 970    private func resolveRoots() {
 971        resolvedRoots = [:]
 972        var refreshed = roots
 973        for (i, root) in roots.enumerated() {
 974            guard let (url, isStale) = try? root.resolve() else {
 975                lastError = "Can't find the folder \(root.path)."
 976                continue
 977            }
 978            if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh }
 979            resolvedRoots[url.path] = url
 980        }
 981        if refreshed != roots {
 982            roots = refreshed
 983            try? saveRoots()
 984        }
 985    }
 986
 987    public func url(forRoot path: String) -> URL? { resolvedRoots[path] }
 988
 989    /// Resolved root paths, sorted.
 990    public var rootPaths: [String] { resolvedRoots.keys.sorted() }
 991
 992    // MARK: - Indexing
 993
 994    /// Reconciles every root off the main thread, then reloads the file list.
 995    public func refresh() async {
 996        isIndexing = true
 997        defer { isIndexing = false }
 998        let reconciler = Reconciler(index: index, settings: settings)
 999        for url in resolvedRoots.values {
1000            do {
1001                _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
1002            } catch {
1003                lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
1004            }
1005        }
1006        try? reloadFiles()
1007    }
1008
1009    private func reloadFiles() throws {
1010        let roots = Set(resolvedRoots.keys)
1011        files = try index.files().compactMap { file in
1012            guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil }
1013            return FileEntry(path: file.path, root: root, kind: file.kind)
1014        }
1015    }
1016
1017    /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
1018    public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
1019        let reconciler = Reconciler(index: index, settings: settings)
1020        for rootURL in roots {
1021            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
1022        }
1023        for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
1024            let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
1025            guard !inRoot.isEmpty else { continue }
1026            _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
1027        }
1028        try? reloadFiles()
1029        for path in paths {
1030            changeVersion += 1
1031            changedFile = (path.standardizedFileURL.path, changeVersion)
1032        }
1033    }
1034
1035    #if os(macOS)
1036    /// Watches every root; changes reconcile the index and are reported through `changedFile`.
1037    public func startWatching() {
1038        let roots = Array(resolvedRoots.values)
1039        guard !roots.isEmpty else { return }
1040        let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
1041            var changed: [URL] = []
1042            var rescans: [URL] = []
1043            for event in events {
1044                switch event {
1045                case .changed(let url): changed.append(url)
1046                case .rescan(let url): rescans.append(url)
1047                }
1048            }
1049            Task { @MainActor [weak self] in
1050                guard let self else { return }
1051                await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:)))
1052            }
1053        }
1054        fsWatcher.start()
1055        watcher = fsWatcher
1056    }
1057    #endif
1058
1059    /// The root an event path belongs to.
1060    private func root(containing url: URL) -> URL? {
1061        resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) }
1062    }
1063
1064    /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form.
1065    func indexForm(_ url: URL) -> URL {
1066        for root in resolvedRoots.values {
1067            let real = root.resolvingSymlinksInPath().path
1068            if real != root.path, url.path.hasPrefix(real + "/") {
1069                return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count))
1070            }
1071        }
1072        return url
1073    }
1074
1075    // MARK: - Queries
1076
1077    public func tree(forRoot path: String) -> FileTreeNode {
1078        FileTreeNode.build(root: path, files: files)
1079    }
1080
1081    public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
1082        (try? index.search(query, overlay: overlay)) ?? []
1083    }
1084
1085    public func quickOpen(_ query: String) -> [FileEntry] {
1086        FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
1087    }
1088}
1089```
1090
1091```swift
1092import Foundation
1093import Observation
1094import OrgCore
1095import OrgDocument
1096import OrgIndex
1097import OrgPresentation
1098
1099/// The file open in a window: its state, its outline, and requests for the editor to show a
1100/// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`.
1101@MainActor
1102@Observable
1103public final class DocumentSession {
1104    public private(set) var path: String?
1105    public private(set) var document: DocumentState?
1106    public private(set) var outline: [OutlineEntry] = []
1107    /// Bumped to ask the editor to reveal an offset.
1108    public private(set) var revealRequest: (offset: Int, id: Int)?
1109    /// New bytes from disk for the open file, for the editor to merge or reload.
1110    public private(set) var diskBytes: (bytes: [UInt8], id: Int)?
1111    public var lastError: String?
1112
1113    private let fileSystem: FileSystem
1114    private var requestID = 0
1115
1116    public init(fileSystem: FileSystem = CoordinatedFileSystem()) {
1117        self.fileSystem = fileSystem
1118    }
1119
1120    public func open(_ path: String) {
1121        do {
1122            guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
1123                lastError = "\((path as NSString).lastPathComponent) no longer exists."
1124                return
1125            }
1126            let document = DocumentState(bytes: bytes)
1127            self.path = path
1128            self.document = document
1129            outline = Presentation.outline(document.tree)
1130            revealRequest = nil
1131            diskBytes = nil
1132        } catch {
1133            lastError = "Can't open \(path): \(error.localizedDescription)"
1134        }
1135    }
1136
1137    /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
1138    /// are used only if the file is unchanged since it was indexed; otherwise the heading is
1139    /// found again by position and title.
1140    public func show(_ location: HeadingLocation) {
1141        if path != location.path { open(location.path) }
1142        guard let document else { return }
1143        let hash = FileRecord.hash(document.mergeBase)
1144        let offset: Int
1145        if hash == location.contentHash {
1146            offset = location.start
1147        } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
1148            offset = outline[location.ordinal].start
1149        } else if let entry = outline.first(where: { $0.title == location.title }) {
1150            offset = entry.start
1151        } else {
1152            return
1153        }
1154        reveal(offset)
1155    }
1156
1157    public func reveal(_ offset: Int) {
1158        requestID += 1
1159        revealRequest = (offset, requestID)
1160    }
1161
1162    /// Called when the workspace sees the open file change on disk.
1163    public func fileChanged(_ path: String) {
1164        guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
1165        guard bytes != document?.mergeBase else { return }
1166        var updated = document ?? DocumentState(bytes: bytes)
1167        _ = updated.diskChanged(to: bytes)
1168        document = updated
1169        outline = Presentation.outline(updated.tree)
1170        requestID += 1
1171        diskBytes = (bytes, requestID)
1172    }
1173}
1174```
1175
1176```diff
1177diff --git a/Sources/OrgIndex/IndexStore.swift b/Sources/OrgIndex/IndexStore.swift
1178index 517c833..556ca69 100644
1179--- a/Sources/OrgIndex/IndexStore.swift
1180+++ b/Sources/OrgIndex/IndexStore.swift
1181@@ -12,12 +12,20 @@ public struct FileState: Sendable, Equatable {
1182 
1183 /// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to,
1184 /// so a caller can tell whether they still apply to an open buffer.
1185-public struct HeadingLocation: Sendable, Equatable {
1186+public struct HeadingLocation: Sendable, Hashable {
1187     public let path: String
1188     public let ordinal: Int
1189     public let title: String
1190     public let start: Int
1191     public let contentHash: String
1192+
1193+    public init(path: String, ordinal: Int, title: String, start: Int, contentHash: String) {
1194+        self.path = path
1195+        self.ordinal = ordinal
1196+        self.title = title
1197+        self.start = start
1198+        self.contentHash = contentHash
1199+    }
1200 }
1201 
1202 /// One reconciliation's worth of changes, applied in a single transaction.
1203```
1204
1205- [ ] **Step 3: Run and commit**
1206
1207Run: `swift test --filter OrgAppTests`
1208
1209```bash
1210git add Package.swift Sources/OrgIndex/IndexStore.swift Sources/OrgApp Tests/OrgAppTests
1211git commit -m "Add workspace and document session models"
1212```
1213
1214---
1215
1216### Task 3: The app
1217
1218**Files:**
1219- Modify: `Package.swift` (add the `Orgstar` executable), `README.md`
1220- Create: `Sources/Orgstar/OrgstarApp.swift`, `ContentView.swift`, `EditorView.swift`, `DevelopmentHooks.swift`, `scripts/build-app.sh`
1221
1222- [ ] **Step 1: Implement**
1223
1224```swift
1225import OrgApp
1226import SwiftUI
1227
1228/// Window-level requests the menu bar makes.
1229@MainActor
1230@Observable
1231final class AppCommands {
1232    var showingQuickOpen = false
1233    var showingFolderPicker = false
1234    var focusSearch = false
1235}
1236
1237@main
1238struct OrgstarApp: App {
1239    @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
1240    @State private var commands = AppCommands()
1241
1242    var body: some Scene {
1243        Window("Orgstar", id: "main") {
1244            switch workspace {
1245            case .success(let model):
1246                ContentView(workspace: model, commands: commands)
1247            case .failure(let error):
1248                ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
1249            }
1250        }
1251        .defaultSize(width: 1280, height: 860)
1252        .commands {
1253            CommandGroup(after: .newItem) {
1254                Button("Add Folder…") { commands.showingFolderPicker = true }
1255                    .keyboardShortcut("o", modifiers: [.command, .shift])
1256                Button("Quick Open…") { commands.showingQuickOpen = true }
1257                    .keyboardShortcut("p")
1258            }
1259            CommandGroup(after: .textEditing) {
1260                Button("Search Notes") { commands.focusSearch = true }
1261                    .keyboardShortcut("f", modifiers: [.command, .shift])
1262            }
1263        }
1264    }
1265}
1266```
1267
1268```swift
1269import OrgApp
1270import OrgIndex
1271import SwiftUI
1272
1273struct ContentView: View {
1274    let workspace: WorkspaceModel
1275    @Bindable var commands: AppCommands
1276    @State private var session = DocumentSession()
1277    @State private var selectedFile: String?
1278    @State private var query = ""
1279    @FocusState private var searchFocused: Bool
1280
1281    var body: some View {
1282        NavigationSplitView {
1283            Sidebar(workspace: workspace, selection: $selectedFile)
1284                .navigationSplitViewColumnWidth(min: 200, ideal: 260)
1285        } content: {
1286            Group {
1287                if query.isEmpty {
1288                    OutlineColumn(session: session)
1289                } else {
1290                    SearchResults(results: workspace.search(query)) { location in
1291                        selectedFile = location.path
1292                        session.show(location)
1293                    }
1294                }
1295            }
1296            .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
1297        } detail: {
1298            if session.document != nil {
1299                EditorView(session: session)
1300            } else {
1301                ContentUnavailableView(
1302                    workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
1303                    systemImage: "doc.text",
1304                    description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
1305                )
1306            }
1307        }
1308        .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
1309        .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
1310        .searchFocused($searchFocused)
1311        .toolbar {
1312            if workspace.isIndexing { ProgressView().controlSize(.small) }
1313        }
1314        .onChange(of: selectedFile) { _, path in
1315            if let path, path != session.path { session.open(path) }
1316        }
1317        .onChange(of: workspace.changedFile?.version) {
1318            if let path = workspace.changedFile?.path { session.fileChanged(path) }
1319        }
1320        .onChange(of: commands.focusSearch) { _, focus in
1321            if focus {
1322                searchFocused = true
1323                commands.focusSearch = false
1324            }
1325        }
1326        .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
1327            guard case .success(let url) = result else { return }
1328            Task {
1329                do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
1330            }
1331        }
1332        .sheet(isPresented: $commands.showingQuickOpen) {
1333            QuickOpenView(workspace: workspace) { entry in
1334                selectedFile = entry.path
1335                commands.showingQuickOpen = false
1336            }
1337        }
1338        .alert("Orgstar", isPresented: errorShown) {
1339            Button("OK") {
1340                workspace.lastError = nil
1341                session.lastError = nil
1342            }
1343        } message: {
1344            Text(workspace.lastError ?? session.lastError ?? "")
1345        }
1346        .task {
1347            DevelopmentHooks.scheduleSnapshot()
1348            if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
1349            await workspace.refresh()
1350            workspace.startWatching()
1351        }
1352    }
1353
1354    private var errorShown: Binding<Bool> {
1355        Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
1356    }
1357}
1358
1359struct Sidebar: View {
1360    let workspace: WorkspaceModel
1361    @Binding var selection: String?
1362
1363    var body: some View {
1364        List(selection: $selection) {
1365            ForEach(workspace.rootPaths, id: \.self) { root in
1366                Section((root as NSString).lastPathComponent) {
1367                    OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
1368                        Label(node.name, systemImage: icon(node))
1369                            .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
1370                            .tag(node.file?.path)
1371                    }
1372                }
1373                .contextMenu {
1374                    Button("Remove Folder") { try? workspace.removeRoot(path: root) }
1375                }
1376            }
1377        }
1378        .listStyle(.sidebar)
1379    }
1380
1381    private func icon(_ node: FileTreeNode) -> String {
1382        guard let file = node.file else { return "folder" }
1383        switch file.kind {
1384        case .org: return "doc.text"
1385        case .archive: return "archivebox"
1386        case .conflict: return "exclamationmark.triangle"
1387        }
1388    }
1389}
1390
1391struct OutlineColumn: View {
1392    let session: DocumentSession
1393
1394    var body: some View {
1395        List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
1396            Button {
1397                session.reveal(entry.start)
1398            } label: {
1399                Text(entry.title.isEmpty ? "(untitled)" : entry.title)
1400                    .lineLimit(1)
1401                    .padding(.leading, CGFloat(entry.level - 1) * 12)
1402            }
1403            .buttonStyle(.plain)
1404        }
1405        .overlay {
1406            if session.document != nil, session.outline.isEmpty {
1407                ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
1408            }
1409        }
1410    }
1411}
1412
1413struct SearchResults: View {
1414    let results: [HeadingLocation]
1415    let open: (HeadingLocation) -> Void
1416
1417    var body: some View {
1418        List(results, id: \.self) { location in
1419            Button {
1420                open(location)
1421            } label: {
1422                VStack(alignment: .leading, spacing: 2) {
1423                    Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
1424                    Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
1425                }
1426            }
1427            .buttonStyle(.plain)
1428        }
1429        .overlay {
1430            if results.isEmpty { ContentUnavailableView.search }
1431        }
1432    }
1433}
1434
1435struct QuickOpenView: View {
1436    let workspace: WorkspaceModel
1437    let open: (FileEntry) -> Void
1438    @State private var query = ""
1439    @Environment(\.dismiss) private var dismiss
1440
1441    var body: some View {
1442        let matches = workspace.quickOpen(query)
1443        VStack(spacing: 0) {
1444            TextField("Open file", text: $query)
1445                .textFieldStyle(.plain)
1446                .font(.title3)
1447                .padding(12)
1448                .onSubmit { if let first = matches.first { open(first) } }
1449            Divider()
1450            List(matches) { entry in
1451                Button {
1452                    open(entry)
1453                } label: {
1454                    VStack(alignment: .leading, spacing: 2) {
1455                        Text(entry.name)
1456                        Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
1457                    }
1458                }
1459                .buttonStyle(.plain)
1460            }
1461        }
1462        .frame(width: 520, height: 360)
1463        .onExitCommand { dismiss() }
1464    }
1465}
1466```
1467
1468```swift
1469import AppKit
1470import OrgApp
1471import OrgEditorAppKit
1472import SwiftUI
1473
1474/// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal
1475/// requests and changes on disk go to the existing one, so folds survive a reload.
1476struct EditorView: NSViewRepresentable {
1477    let session: DocumentSession
1478
1479    final class Coordinator {
1480        var editor: OrgEditor?
1481        var path: String?
1482        var revealID = 0
1483        var diskID = 0
1484    }
1485
1486    func makeCoordinator() -> Coordinator { Coordinator() }
1487
1488    func makeNSView(context: Context) -> NSView {
1489        NSView()
1490    }
1491
1492    func updateNSView(_ container: NSView, context: Context) {
1493        let coordinator = context.coordinator
1494        if coordinator.path != session.path {
1495            coordinator.path = session.path
1496            container.subviews.forEach { $0.removeFromSuperview() }
1497            coordinator.editor = nil
1498            if let document = session.document {
1499                let editor = OrgEditor(document: document, frame: container.bounds, editable: false)
1500                let scroll = editor.makeScrollView()
1501                scroll.frame = container.bounds
1502                scroll.autoresizingMask = [.width, .height]
1503                container.addSubview(scroll)
1504                container.window?.makeFirstResponder(editor.textView)
1505                coordinator.editor = editor
1506            }
1507        }
1508        if let request = session.revealRequest, request.id != coordinator.revealID {
1509            coordinator.revealID = request.id
1510            coordinator.editor?.reveal(request.offset)
1511        }
1512        if let disk = session.diskBytes, disk.id != coordinator.diskID {
1513            coordinator.diskID = disk.id
1514            coordinator.editor?.diskChanged(to: disk.bytes)
1515        }
1516    }
1517}
1518```
1519
1520```swift
1521import AppKit
1522
1523/// Switches for checking the app from scripts, read from the environment:
1524/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
1525/// - `ORGSTAR_OPEN`: a file to select at launch.
1526/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
1527///   window to this PNG path and quit. Rendering from inside the app needs no screen-recording
1528///   permission.
1529enum DevelopmentHooks {
1530    static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
1531
1532    @MainActor
1533    static func scheduleSnapshot() {
1534        let environment = ProcessInfo.processInfo.environment
1535        guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
1536        let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
1537        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
1538            if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview,
1539               let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
1540                view.cacheDisplay(in: view.bounds, to: bitmap)
1541                try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
1542            }
1543            NSApp.terminate(nil)
1544        }
1545    }
1546}
1547```
1548
1549```sh
1550#!/bin/sh
1551# Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use.
1552# Usage: scripts/build-app.sh [version]
1553set -eu
1554
1555cd "$(dirname "$0")/.."
1556version="${1:-0.1.0}"
1557app=".build/app/Orgstar.app"
1558
1559swift build -c release --product Orgstar
1560binary="$(swift build -c release --show-bin-path)/Orgstar"
1561
1562rm -rf "$app"
1563mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
1564cp "$binary" "$app/Contents/MacOS/Orgstar"
1565
1566cat > "$app/Contents/Info.plist" <<EOF
1567<?xml version="1.0" encoding="UTF-8"?>
1568<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1569<plist version="1.0">
1570<dict>
1571    <key>CFBundleIdentifier</key>
1572    <string>sh.krz.orgstar</string>
1573    <key>CFBundleName</key>
1574    <string>Orgstar</string>
1575    <key>CFBundleDisplayName</key>
1576    <string>Orgstar</string>
1577    <key>CFBundleExecutable</key>
1578    <string>Orgstar</string>
1579    <key>CFBundlePackageType</key>
1580    <string>APPL</string>
1581    <key>CFBundleShortVersionString</key>
1582    <string>${version}</string>
1583    <key>CFBundleVersion</key>
1584    <string>${version}</string>
1585    <key>LSMinimumSystemVersion</key>
1586    <string>26.0</string>
1587    <key>LSApplicationCategoryType</key>
1588    <string>public.app-category.productivity</string>
1589    <key>NSHighResolutionCapable</key>
1590    <true/>
1591    <key>NSPrincipalClass</key>
1592    <string>NSApplication</string>
1593</dict>
1594</plist>
1595EOF
1596
1597codesign --force --sign - "$app"
1598echo "$app"
1599```
1600
1601- [ ] **Step 2: Build, run and snapshot**
1602
1603Run: `scripts/build-app.sh`, then with a seeded data directory:
1604`ORGSTAR_DATA_DIR=<dir> ORGSTAR_OPEN=<file.org> ORGSTAR_SNAPSHOT=/tmp/shot.png .build/app/Orgstar.app/Contents/MacOS/Orgstar`
1605Check: 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.
1606
1607Manual: 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.
1608
1609- [ ] **Step 3: Commit**
1610
1611```bash
1612git add Package.swift README.md Sources/Orgstar scripts
1613git commit -m "Add the read-only viewer app"
1614```
1615
1616---
1617
1618### Task 4: Share paragraph styles
1619
1620Per-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.
1621
1622**Files:**
1623- Modify: `Sources/OrgEditorAppKit/OrgEditor.swift`
1624
1625- [ ] **Step 1: Implement**
1626
1627```diff
1628@@ -59,6 +59,13 @@ public final class OrgEditor: NSObject {
1629     /// passes reach it. Always at a line start.
1630     private(set) var styledUpTo = 0
1631     private var stylingScheduled = false
1632+    /// One paragraph style object per indent pair, shared by every line that uses it.
1633+    private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:]
1634+
1635+    private struct IndentKey: Hashable {
1636+        let firstLine: Int
1637+        let wrapped: Int
1638+    }
1639 
1640     /// Styled before the first render: more than a screenful.
1641     static let firstStyleChunk = 30_000
1642@@ -253,7 +260,7 @@ public final class OrgEditor: NSObject {
1643         for run in Presentation.indents(document.tree, in: range) {
1644             guard let clipped = clip(run.range, to: range) else { continue }
1645             guard run.firstLine == run.wrapped else {
1646-                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
1647+                storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
1648                 continue
1649             }
1650             // Body lines wrap under their own text: past leading blanks, and past a list bullet
1651@@ -263,7 +270,7 @@ public final class OrgEditor: NSObject {
1652                 let line = text.lineRange(for: NSRange(location: location, length: 0))
1653                 let hanging = Self.hangingColumns(text.substring(with: line))
1654                 let lineRange = NSIntersectionRange(line, clipped)
1655-                storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange)
1656+                storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
1657                 location = NSMaxRange(line)
1658             }
1659         }
1660@@ -273,6 +280,14 @@ public final class OrgEditor: NSObject {
1661         }
1662     }
1663 
1664+    private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
1665+        let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
1666+        if let style = paragraphStyles[key] { return style }
1667+        let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
1668+        paragraphStyles[key] = style
1669+        return style
1670+    }
1671+
1672     private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
1673         let clipped = run.clamped(to: range)
1674         return clipped.isEmpty ? nil : NSRange(clipped)
1675```
1676
1677- [ ] **Step 2: Run the gates; commit**
1678
1679Run: `ORGSTAR_GATES=1 ORGSTAR_GATE_FILE=<large real file> swift test -c release --filter GateTests`
1680
1681```bash
1682git add Sources/OrgEditorAppKit/OrgEditor.swift
1683git commit -m "Share paragraph styles across lines"
1684```