Commit d9a8722155
Verified · cmc
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 | ||
| 52 | diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift | ||
| 53 | index 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 | +} | ||
| 163 | diff --git a/Tests/OrgPresentationTests/PresentationTests.swift b/Tests/OrgPresentationTests/PresentationTests.swift | ||
| 164 | index 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 | ||
| 220 | import OrgCore | ||
| 221 | |||
| 222 | /// Which headings are folded: whole subtrees, or only their own bodies. | ||
| 223 | public 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. | ||
| 234 | public 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 | |||
| 242 | public 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 | |||
| 255 | extension 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 | ||
| 378 | diff --git a/Sources/OrgDocument/ViewState.swift b/Sources/OrgDocument/ViewState.swift | ||
| 379 | index 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 | |||
| 423 | diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift | ||
| 424 | index 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 | } | ||
| 613 | diff --git a/Sources/OrgPresentation/Presentation.swift b/Sources/OrgPresentation/Presentation.swift | ||
| 614 | index 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 | |||
| 663 | Run: `swift test` | ||
| 664 | |||
| 665 | ```bash | ||
| 666 | git add Sources/OrgPresentation Sources/OrgDocument/ViewState.swift Sources/OrgEditorAppKit Tests/OrgPresentationTests Tests/OrgEditorAppKitTests/EditorTests.swift | ||
| 667 | git 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 | ||
| 682 | import Foundation | ||
| 683 | import OrgCore | ||
| 684 | import OrgIndex | ||
| 685 | import Testing | ||
| 686 | @testable import OrgApp | ||
| 687 | |||
| 688 | /// A temporary folder that removes itself. | ||
| 689 | final 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 | |||
| 710 | struct 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 | ||
| 731 | struct 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 | ||
| 770 | struct 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 | ||
| 802 | import Foundation | ||
| 803 | import OrgIndex | ||
| 804 | |||
| 805 | /// One file the workspace knows about. | ||
| 806 | public 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. | ||
| 820 | public 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. | ||
| 861 | public 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 | ||
| 891 | import Foundation | ||
| 892 | import Observation | ||
| 893 | import OrgIndex | ||
| 894 | import OrgWorkspace | ||
| 895 | |||
| 896 | /// The folders the user added, the index over them, and the queries the window runs. | ||
| 897 | @MainActor | ||
| 898 | @Observable | ||
| 899 | public 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 | ||
| 1092 | import Foundation | ||
| 1093 | import Observation | ||
| 1094 | import OrgCore | ||
| 1095 | import OrgDocument | ||
| 1096 | import OrgIndex | ||
| 1097 | import 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 | ||
| 1103 | public 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 | ||
| 1177 | diff --git a/Sources/OrgIndex/IndexStore.swift b/Sources/OrgIndex/IndexStore.swift | ||
| 1178 | index 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 | |||
| 1207 | Run: `swift test --filter OrgAppTests` | ||
| 1208 | |||
| 1209 | ```bash | ||
| 1210 | git add Package.swift Sources/OrgIndex/IndexStore.swift Sources/OrgApp Tests/OrgAppTests | ||
| 1211 | git 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 | ||
| 1225 | import OrgApp | ||
| 1226 | import SwiftUI | ||
| 1227 | |||
| 1228 | /// Window-level requests the menu bar makes. | ||
| 1229 | @MainActor | ||
| 1230 | @Observable | ||
| 1231 | final class AppCommands { | ||
| 1232 | var showingQuickOpen = false | ||
| 1233 | var showingFolderPicker = false | ||
| 1234 | var focusSearch = false | ||
| 1235 | } | ||
| 1236 | |||
| 1237 | @main | ||
| 1238 | struct 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 | ||
| 1269 | import OrgApp | ||
| 1270 | import OrgIndex | ||
| 1271 | import SwiftUI | ||
| 1272 | |||
| 1273 | struct 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 | |||
| 1359 | struct 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 | |||
| 1391 | struct 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 | |||
| 1413 | struct 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 | |||
| 1435 | struct 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 | ||
| 1469 | import AppKit | ||
| 1470 | import OrgApp | ||
| 1471 | import OrgEditorAppKit | ||
| 1472 | import 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. | ||
| 1476 | struct 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 | ||
| 1521 | import 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. | ||
| 1529 | enum 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] | ||
| 1553 | set -eu | ||
| 1554 | |||
| 1555 | cd "$(dirname "$0")/.." | ||
| 1556 | version="${1:-0.1.0}" | ||
| 1557 | app=".build/app/Orgstar.app" | ||
| 1558 | |||
| 1559 | swift build -c release --product Orgstar | ||
| 1560 | binary="$(swift build -c release --show-bin-path)/Orgstar" | ||
| 1561 | |||
| 1562 | rm -rf "$app" | ||
| 1563 | mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" | ||
| 1564 | cp "$binary" "$app/Contents/MacOS/Orgstar" | ||
| 1565 | |||
| 1566 | cat > "$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> | ||
| 1595 | EOF | ||
| 1596 | |||
| 1597 | codesign --force --sign - "$app" | ||
| 1598 | echo "$app" | ||
| 1599 | ``` | ||
| 1600 | |||
| 1601 | - [ ] **Step 2: Build, run and snapshot** | ||
| 1602 | |||
| 1603 | Run: `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` | ||
| 1605 | Check: outline column lists the file's headings by level; the editor shows hidden leading stars, dimmed markup, body indents, and wrapped list lines under their text; the title is the file name. | ||
| 1606 | |||
| 1607 | Manual: add a folder with ⇧⌘O; ⌘P opens a file; ⇧⌘F search opens results that reveal the heading; TAB and Shift-TAB fold; editing the file in Emacs reloads the view. | ||
| 1608 | |||
| 1609 | - [ ] **Step 3: Commit** | ||
| 1610 | |||
| 1611 | ```bash | ||
| 1612 | git add Package.swift README.md Sources/Orgstar scripts | ||
| 1613 | git commit -m "Add the read-only viewer app" | ||
| 1614 | ``` | ||