krz/orgstar

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

Commit d9a8722155

d9a8722155ce29cb3f6d12442d14331552e5cf07

parent: 7af09a499f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:54 UTC

Add viewer plan

Layout: unified · split

docs/plans/2026-10-04-viewer.md added +1614
@@ -0,0 +1,1614 @@
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```