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