Read-only viewer !9

merged merged by cmc on 2026-10-05 03:15 UTC · krz/orgstar:phase1-viewer into main

19 files changed, +2954 −42

Layout: unified · split

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