# Read-only Viewer Implementation Plan > **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. **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`). **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. **Tech Stack:** Swift 6.2 tools, SwiftUI, AppKit, TextKit 2, Swift Testing. **Spec:** `docs/design.md`, "Phases" (phase 1 scope: Mac app, rendering, folding). ## Global Constraints - The viewer never writes to the user's files; the editor runs with `editable: false`. - `OrgApp` and `OrgPresentation` import no AppKit or SwiftUI. - Decisions (user): SwiftPM executable plus a bundle script, not an Xcode project; SwiftUI shell with the AppKit editor. Bundle identifier `sh.krz.orgstar`. ## Development hooks `ORGSTAR_DATA_DIR` moves the index and roots out of Application Support; `ORGSTAR_OPEN` selects a file at launch; `ORGSTAR_SNAPSHOT=` 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. ## File structure | File | Responsibility | | --- | --- | | `Sources/OrgPresentation/Folding.swift` | `FoldState`, `GlobalVisibility`, `OutlineEntry`, cycling, `#+STARTUP` | | `Sources/OrgPresentation/Presentation.swift` | `hiddenRanges` with body folds | | `Sources/OrgDocument/ViewState.swift` | `bodyFolds` mapped and pruned with edits | | `Sources/OrgEditorAppKit/OrgEditor.swift` | Cycling, reveal, reload from disk, read-only mode, hanging indents for wrapped lines | | `Sources/OrgApp/FileTree.swift` | `FileEntry`, `FileTreeNode`, `FuzzyMatch` | | `Sources/OrgApp/WorkspaceModel.swift` | Roots, index, reconciliation, watching, queries | | `Sources/OrgApp/DocumentSession.swift` | Open file, outline, reveal and reload requests | | `Sources/Orgstar/*.swift` | The SwiftUI app, editor bridge and development hooks | | `scripts/build-app.sh` | `Orgstar.app` with `Info.plist`, signed ad hoc | --- ### Task 1: Folding **Files:** - Create: `Sources/OrgPresentation/Folding.swift` - Modify: `Sources/OrgPresentation/Presentation.swift`, `Sources/OrgDocument/ViewState.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift` - Test: `Tests/OrgPresentationTests/PresentationTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift` **Interfaces:** - 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(_:)`. - [ ] **Step 1: Write the failing tests** ```diff diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift index 494399a..cdf9b2d 100644 --- a/Tests/OrgEditorAppKitTests/EditorTests.swift +++ b/Tests/OrgEditorAppKitTests/EditorTests.swift @@ -1,6 +1,7 @@ import AppKit import OrgCore import OrgDocument +import OrgPresentation import Testing @testable import OrgEditorAppKit @@ -77,16 +78,45 @@ struct FoldingTests { let h = Harness(outline) #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"]) h.caret(at: 1) - #expect(h.editor.toggleFoldAtCaret()) + #expect(h.editor.cycleAtCaret()) #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"]) - h.editor.toggleFoldAtCaret() + h.editor.cycleAtCaret() + #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"]) + h.editor.cycleAtCaret() #expect(h.visibleLines.count == 6) } + @Test func globalCycleAndStartup() { + let h = Harness(outline) + h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) + h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) + #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"]) + h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) + #expect(h.visibleLines.count == 6) + #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"]) + } + + @Test func revealUnfoldsWhatHidesTheTarget() { + let h = Harness("#+STARTUP: overview\n" + outline) + let target = h.offset(of: "more") + h.editor.reveal(target) + #expect(h.visibleLines.contains("more\n")) + #expect(h.caret == target) + } + + @Test func readOnlyAndReloadKeepFolds() { + let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false) + #expect(!editor.textView.isEditable) + editor.setFolds(FoldState(folds: [19])) + editor.diskChanged(to: Array(("new line\n" + outline).utf8)) + #expect(editor.document.text == "new line\n" + outline) + #expect(editor.foldState.folds == [28]) + } + @Test func tabFoldsOnlyOnHeadings() { let h = Harness(outline) h.caret(at: 5) - #expect(!h.editor.toggleFoldAtCaret()) + #expect(!h.editor.cycleAtCaret()) h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) #expect(h.visibleLines == ["* a\n", "* c\n"]) } @@ -94,7 +124,7 @@ struct FoldingTests { @Test func caretMovesOverFoldedText() { let h = Harness(outline) h.caret(at: 1) - h.editor.toggleFoldAtCaret() + h.editor.cycleAtCaret() h.caret(at: 0) h.textView.moveDown(nil) #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text")) @@ -108,7 +138,7 @@ struct FoldingTests { @Test func copyingAcrossAFoldIncludesHiddenText() { let h = Harness(outline) h.caret(at: 1) - h.editor.toggleFoldAtCaret() + h.editor.cycleAtCaret() h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text"))) let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)")) defer { pasteboard.releaseGlobally() } @@ -119,7 +149,7 @@ struct FoldingTests { @Test func foldsFollowEdits() { let h = Harness(outline) h.caret(at: h.offset(of: "* c") + 1) - h.editor.toggleFoldAtCaret() + h.editor.cycleAtCaret() h.caret(at: 0) h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0)) #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"]) @@ -177,7 +207,7 @@ struct EditingTests { @Test func accessibilityReadsTheWholeText() { let h = Harness(outline) h.caret(at: 1) - h.editor.toggleFoldAtCaret() + h.editor.cycleAtCaret() #expect(h.textView.accessibilityValue() as? String == outline) h.textView.setSelectedRange(NSRange(location: 0, length: 3)) #expect(h.textView.accessibilitySelectedText() == "* a") @@ -280,3 +310,13 @@ struct InitialStylingTests { #expect(h.editor.styledUpTo == before + 3) } } + +struct HangingIndentTests { + @Test(arguments: [ + ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4), + ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10), + ]) + func columns(line: String, expected: Int) { + #expect(OrgEditor.hangingColumns(line) == expected) + } +} diff --git a/Tests/OrgPresentationTests/PresentationTests.swift b/Tests/OrgPresentationTests/PresentationTests.swift index 6e1ca3d..7007d92 100644 --- a/Tests/OrgPresentationTests/PresentationTests.swift +++ b/Tests/OrgPresentationTests/PresentationTests.swift @@ -76,3 +76,47 @@ struct FoldTests { #expect(Presentation.headings(tree) == [0, 9, 19]) } } + +struct CyclingTests { + let text = "* a\nbody\n** b\nmore\n*** c\ndeep\n* d\n" + var tree: OrgTree { OrgParser.parse(text) } + + @Test func outline() { + let entries = Presentation.outline(tree) + #expect(entries.map(\.title) == ["a", "b", "c", "d"]) + #expect(entries.map(\.parent) == [nil, 0, 1, nil]) + #expect(entries.map(\.isTopLevel) == [true, false, false, true]) + } + + @Test func localCycle() { + var state = FoldState() + state = Presentation.cycle(0, in: tree, state) + #expect(state == FoldState(folds: [0])) + state = Presentation.cycle(0, in: tree, state) + #expect(state == FoldState(folds: [9])) + state = Presentation.cycle(0, in: tree, state) + #expect(state == FoldState()) + #expect(Presentation.cycle(30, in: tree, FoldState(folds: [30])) == FoldState()) + } + + @Test func globalStates() { + #expect(Presentation.foldState(for: .overview, in: tree) == FoldState(folds: [0, 30])) + #expect(Presentation.foldState(for: .contents, in: tree) == FoldState(bodyFolds: [0, 9, 19, 30])) + #expect(Presentation.cycleGlobal(from: nil, in: tree).0 == .overview) + #expect(Presentation.cycleGlobal(from: .overview, in: tree).0 == .contents) + #expect(Presentation.cycleGlobal(from: .contents, in: tree).0 == .showAll) + } + + @Test func bodyFoldsKeepChildHeadings() { + #expect(Presentation.hiddenRanges(tree, folds: [], bodyFolds: [0, 9, 19, 30]) == [4..<9, 14..<19, 25..<30]) + } + + @Test func startup() { + #expect(Presentation.startupFoldState(OrgParser.parse("#+STARTUP: content\n" + text)) != nil) + #expect(Presentation.startupFoldState(OrgParser.parse(text)) == nil) + let levels = OrgParser.parse("#+startup: show2levels\n" + text) + let state = Presentation.startupFoldState(levels) + #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2) + #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) + } +} ``` - [ ] **Step 2: Implement** ```swift import OrgCore /// Which headings are folded: whole subtrees, or only their own bodies. public struct FoldState: Sendable, Equatable { public var folds: Set public var bodyFolds: Set public init(folds: Set = [], bodyFolds: Set = []) { self.folds = folds self.bodyFolds = bodyFolds } } /// org's global visibility states, cycled by Shift-TAB. public enum GlobalVisibility: Sendable, Equatable { /// Top-level headings only. case overview /// Every heading, no bodies. case contents case showAll } public struct OutlineEntry: Sendable, Equatable { /// Start offset of the heading line. public let start: Int public let level: Int /// The title as written. public let title: String /// End of the heading's section. public let end: Int /// Index of the parent entry. public let parent: Int? public let isTopLevel: Bool } extension Presentation { /// Every heading in document order. public static func outline(_ tree: OrgTree) -> [OutlineEntry] { var entries: [OutlineEntry] = [] collectOutline(tree.root, parent: nil, topLevel: true, &entries) return entries } private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) { for section in node.children where section.kind == .section { guard let heading = section.firstChild(.heading) else { continue } let index = entries.count entries.append(OutlineEntry( start: heading.range.lowerBound, level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1, title: heading.firstChild(.title)?.text ?? "", end: section.range.upperBound, parent: parent, isTopLevel: topLevel )) collectOutline(section, parent: index, topLevel: false, &entries) } } /// TAB on a heading: folded, then its children, then the whole subtree, then folded again. /// A heading without children goes straight from folded to unfolded. public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState { let outline = outline(tree) guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state } let entry = outline[index] let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start) let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start) var next = state func unfoldSubtree() { next.folds.subtract([heading] + descendants) next.bodyFolds.subtract([heading] + descendants) } if state.folds.contains(heading) { unfoldSubtree() next.folds.formUnion(children) } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) { unfoldSubtree() } else { unfoldSubtree() next.folds.insert(heading) } return next } /// Shift-TAB: overview, contents, show all, and around. public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) { let next: GlobalVisibility = switch current { case .overview: .contents case .contents: .showAll case .showAll, nil: .overview } return (next, foldState(for: next, in: tree)) } public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState { let outline = outline(tree) switch visibility { case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start))) case .contents: return FoldState(bodyFolds: Set(outline.map(\.start))) case .showAll: return FoldState() } } /// Headings of levels 1 through `levels` visible, no bodies. public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState { let outline = outline(tree) return FoldState( folds: Set(outline.filter { $0.level == levels }.map(\.start)), bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start)) ) } /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none. /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`... public static func startupFoldState(_ tree: OrgTree) -> FoldState? { var result: FoldState? for option in startupOptions(tree) { switch option.lowercased() { case "overview", "fold": result = foldState(for: .overview, in: tree) case "content": result = foldState(for: .contents, in: tree) case "showall", "nofold", "showeverything": result = FoldState() case let word where word.hasPrefix("show") && word.hasSuffix("levels"): if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 { result = foldState(showingLevels: levels, in: tree) } default: break } } return result } /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside /// blocks and paragraphs is never read as a setting. public static func startupOptions(_ tree: OrgTree) -> [String] { var options: [String] = [] func visit(_ node: GreenNode) { for case .node(let child) in node.children { switch child.kind { case .keyword: let text = child.text if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"), let colon = text.firstIndex(of: ":") { options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init) } case .document, .zerothSection, .section, .drawer: visit(child) default: break } } } visit(tree.green) return options } } ``` ```diff diff --git a/Sources/OrgDocument/ViewState.swift b/Sources/OrgDocument/ViewState.swift index 9cdf35a..6f84444 100644 --- a/Sources/OrgDocument/ViewState.swift +++ b/Sources/OrgDocument/ViewState.swift @@ -3,12 +3,15 @@ import OrgCore /// Per-window state for one document: selection and folds, in UTF-16 offsets. public struct ViewState: Sendable, Equatable { public var selection: [Range] - /// Start offsets of folded headings. + /// Start offsets of headings whose whole subtree is folded. public var folds: Set + /// Start offsets of headings whose own body is folded; their child headings stay visible. + public var bodyFolds: Set - public init(selection: [Range] = [0..<0], folds: Set = []) { + public init(selection: [Range] = [0..<0], folds: Set = [], bodyFolds: Set = []) { self.selection = selection self.folds = folds + self.bodyFolds = bodyFolds } /// Maps through non-overlapping edits given in old coordinates. @@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable { let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } return ViewState( selection: selection.map { mapOffset($0.lowerBound, sorted).. ViewState { - guard !folds.isEmpty else { return self } - return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) }) + guard !folds.isEmpty || !bodyFolds.isEmpty else { return self } + return ViewState( + selection: selection, + folds: folds.filter { isHeadingStart($0, tree.root) }, + bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) } + ) } } diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift index b7c3dc4..2a52c5e 100644 --- a/Sources/OrgEditorAppKit/OrgEditor.swift +++ b/Sources/OrgEditorAppKit/OrgEditor.swift @@ -65,12 +65,13 @@ public final class OrgEditor: NSObject { /// Styled per run-loop turn afterwards. static let styleChunk = 200_000 - public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) { + public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) { self.document = document textView = NSTextView(usingTextLayoutManager: true) super.init() textView.frame = frame textView.isRichText = false + textView.isEditable = editable textView.allowsUndo = true textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false @@ -87,6 +88,7 @@ public final class OrgEditor: NSObject { textContentStorage.delegate = self load(document.text) + if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } } /// Replaces the storage's text. Setting the attributed string directly is about 100x @@ -153,37 +155,77 @@ public final class OrgEditor: NSObject { if document.text != before { let edits = lineEdits(from: before, to: document.text) load(document.text) - setFolds(view.mapped(through: edits).folds) + let mapped = view.mapped(through: edits) + setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) } return outcome } + // MARK: - Disk + + /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept + /// its headings. + public func diskChanged(to bytes: [UInt8]) { + let edits: [TextEdit] + switch document.diskChanged(to: bytes) { + case .reloaded(let changes), .merged(let changes): edits = changes + case .unchanged, .conflict: return + } + let mapped = view.mapped(through: edits) + load(document.text) + setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) + } + // MARK: - Folding - public func setFolds(_ folds: Set) { - view.folds = folds + public var foldState: FoldState { + FoldState(folds: view.folds, bodyFolds: view.bodyFolds) + } + + private var globalVisibility: GlobalVisibility? + + public func setFolds(_ state: FoldState) { + view.folds = state.folds + view.bodyFolds = state.bodyFolds view = view.pruned(to: document.tree) - folded.set(view.folds) - hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds)) + updateHidden() textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) textView.needsDisplay = true let caret = textView.selectedRange().location if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } } - /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading. + private func updateHidden() { + folded.set(view.folds.union(view.bodyFolds)) + hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds)) + } + + /// TAB on a heading: folded, children, subtree. Returns false off a heading. @discardableResult - public func toggleFoldAtCaret() -> Bool { + public func cycleAtCaret() -> Bool { guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } - var folds = view.folds - if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) } - setFolds(folds) + setFolds(Presentation.cycle(heading, in: document.tree, foldState)) return true } - /// Folds every heading, or unfolds everything if anything is folded. - public func toggleAllFolds() { - setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : []) + /// Shift-TAB: overview, contents, show all. + public func cycleGlobal() { + let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree) + globalVisibility = visibility + setFolds(state) + } + + /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. + public func reveal(_ offset: Int) { + let target = max(0, min(offset, utf16Length)) + var state = foldState + for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end { + state.folds.remove(entry.start) + state.bodyFolds.remove(entry.start) + } + setFolds(state) + setCaret(target) + textView.scrollRangeToVisible(NSRange(location: target, length: 0)) } private func setCaret(_ offset: Int) { @@ -207,9 +249,23 @@ public final class OrgEditor: NSObject { guard !range.isEmpty else { return } let whole = NSRange(range) storage.setAttributes(theme.base, range: whole) + let text = storage.string as NSString for run in Presentation.indents(document.tree, in: range) { guard let clipped = clip(run.range, to: range) else { continue } - storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) + guard run.firstLine == run.wrapped else { + storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) + continue + } + // Body lines wrap under their own text: past leading blanks, and past a list bullet + // and checkbox, as adaptive-wrap does in Emacs. + var location = clipped.location + while location < NSMaxRange(clipped) { + let line = text.lineRange(for: NSRange(location: location, length: 0)) + let hanging = Self.hangingColumns(text.substring(with: line)) + let lineRange = NSIntersectionRange(line, clipped) + storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange) + location = NSMaxRange(line) + } } for run in Presentation.styles(document.tree, in: range) { guard let clipped = clip(run.range, to: range) else { continue } @@ -301,6 +357,34 @@ public final class OrgEditor: NSObject { onEditTiming?(clock.now - start) } + /// Columns a wrapped continuation of `line` should start past the line's own start. + nonisolated static func hangingColumns(_ line: String) -> Int { + var columns = 0 + var rest = Substring(line) + while let c = rest.first, c == " " || c == "\t" { + columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1 + rest = rest.dropFirst() + } + // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox. + var bullet = 0 + if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) { + bullet = 1 + } else { + let digits = rest.prefix { $0.isASCII && $0.isNumber } + if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" { + bullet = digits.count + 1 + } + } + guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns } + columns += bullet + 1 + let afterBullet = rest.dropFirst(bullet + 1) + if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!), + afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " { + columns += 4 + } + return columns + } + /// Whether a heading line touches `start.. Bool { @@ -332,9 +416,9 @@ public final class OrgEditor: NSObject { extension OrgEditor: NSTextViewDelegate { public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { switch selector { - case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret() + case #selector(NSResponder.insertTab(_:)): return cycleAtCaret() case #selector(NSResponder.insertBacktab(_:)): - toggleAllFolds() + cycleGlobal() return true default: return false } diff --git a/Sources/OrgPresentation/Presentation.swift b/Sources/OrgPresentation/Presentation.swift index 504e0b5..1813443 100644 --- a/Sources/OrgPresentation/Presentation.swift +++ b/Sources/OrgPresentation/Presentation.swift @@ -130,25 +130,31 @@ public enum Presentation { } } - /// Ranges to hide for folded headings, given by their start offsets: from the end of the - /// heading line to the end of its section. Sorted, without nested duplicates. - public static func hiddenRanges(_ tree: OrgTree, folds: Set) -> [Range] { - guard !folds.isEmpty else { return [] } + /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its + /// section; one in `bodyFolds` hides only its own body, up to its first child heading. + /// Sorted, without nested duplicates. + public static func hiddenRanges(_ tree: OrgTree, folds: Set, bodyFolds: Set = []) -> [Range] { + guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] } var ranges: [Range] = [] - collectHidden(tree.root, folds, &ranges) + collectHidden(tree.root, folds, bodyFolds, &ranges) return ranges } - private static func collectHidden(_ node: SyntaxNode, _ folds: Set, _ ranges: inout [Range]) { + private static func collectHidden(_ node: SyntaxNode, _ folds: Set, _ bodyFolds: Set, _ ranges: inout [Range]) { for section in node.children where section.kind == .section { - guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue } - if folds.contains(heading.range.lowerBound) { + guard let heading = section.firstChild(.heading) else { continue } + let start = heading.range.lowerBound + if folds.contains(start) { if heading.range.upperBound < section.range.upperBound { ranges.append(heading.range.upperBound.. URL { let file = url.appendingPathComponent(path) try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) try Data(text.utf8).write(to: file) return file.standardizedFileURL } } struct FileTreeTests { @Test func foldersFirstThenFiles() { let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org) } let tree = FileTreeNode.build(root: "/r", files: files) #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"]) #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"]) #expect(tree.children?[1].children == nil) } @Test func fuzzyMatching() { #expect(FuzzyMatch.score("wn", "work/notes.org") != nil) #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil) let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) } #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"]) #expect(FuzzyMatch.rank("", files).count == 3) } } @MainActor struct WorkspaceModelTests { @Test func addSearchRemoveAndPersist() async throws { let notes = try Folder() let support = try Folder() try notes.write("a.org", "* Groceries\nbuy apples\n") try notes.write("sub/b.org", "* Taxes\n") try notes.write("c.sync-conflict-1.org", "* Taxes copy\n") let model = try WorkspaceModel(directory: support.url) try await model.addRoot(notes.url) #expect(model.files.count == 3) #expect(model.search("apples").map(\.title) == ["Groceries"]) #expect(model.quickOpen("bo").map(\.name) == ["b.org"]) let root = try #require(model.rootPaths.first) #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"]) let reopened = try WorkspaceModel(directory: support.url) #expect(reopened.roots.count == 1) #expect(reopened.files.count == 3) try reopened.removeRoot(path: root) #expect(reopened.roots.isEmpty) #expect(reopened.files.isEmpty) #expect(reopened.search("apples").isEmpty) } @Test func changedFilesAreReindexed() async throws { let notes = try Folder() let file = try notes.write("a.org", "* Old\n") let model = try WorkspaceModel(directory: nil) try await model.addRoot(notes.url) try notes.write("a.org", "* New\n") await model.filesChanged([file], rescan: []) #expect(model.search("new").count == 1) #expect(model.changedFile?.path == file.path) } } @MainActor struct DocumentSessionTests { @Test func openShowAndReload() throws { let notes = try Folder() let file = try notes.write("a.org", "* One\n* Two\n") let session = DocumentSession() session.open(file.path) #expect(session.outline.map(\.title) == ["One", "Two"]) let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale") session.show(location) #expect(session.revealRequest?.offset == 6) try notes.write("a.org", "intro\n* One\n* Two\n") session.fileChanged(file.path) #expect(session.outline.map(\.start) == [6, 12]) #expect(session.diskBytes != nil) session.show(location) #expect(session.revealRequest?.offset == 12) } @Test func missingFileReportsAnError() { let session = DocumentSession() session.open("/nonexistent/a.org") #expect(session.lastError != nil) #expect(session.document == nil) } } ``` - [ ] **Step 2: Implement** ```swift import Foundation import OrgIndex /// One file the workspace knows about. public struct FileEntry: Sendable, Hashable, Identifiable { public let path: String public let root: String public let kind: FileKind public var id: String { path } public var name: String { (path as NSString).lastPathComponent } /// Path below the root, for display and agenda globs. public var relativePath: String { path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path } } /// A folder or file in the sidebar. public struct FileTreeNode: Sendable, Hashable, Identifiable { public let name: String /// The file's or folder's absolute path. public let path: String public let file: FileEntry? /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle. public let children: [FileTreeNode]? public var id: String { path } /// Folders first, then files, each by name. Conflict copies are listed with their files. public static func build(root: String, files: [FileEntry]) -> FileTreeNode { final class Folder { var folders: [String: Folder] = [:] var files: [FileEntry] = [] } let top = Folder() for file in files where file.root == root { var folder = top for component in file.relativePath.split(separator: "/").dropLast() { let name = String(component) if folder.folders[name] == nil { folder.folders[name] = Folder() } folder = folder.folders[name]! } folder.files.append(file) } func node(_ folder: Folder, name: String, path: String) -> FileTreeNode { let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map { node(folder.folders[$0]!, name: $0, path: path + "/" + $0) } let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map { FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil) } return FileTreeNode(name: name, path: path, file: nil, children: folders + files) } return node(top, name: (root as NSString).lastPathComponent, path: root) } } /// Subsequence matching for quick open: every query character must appear in order. Matches /// at word starts and runs of consecutive characters score higher; shorter names win ties. public enum FuzzyMatch { public static func score(_ query: String, _ candidate: String) -> Int? { let query = Array(query.lowercased().filter { !$0.isWhitespace }) let text = Array(candidate.lowercased()) guard !query.isEmpty else { return 0 } var score = 0 var q = 0 var previousMatch = -2 for (i, c) in text.enumerated() where q < query.count && c == query[q] { score += 1 if i == previousMatch + 1 { score += 5 } if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 } previousMatch = i q += 1 } guard q == query.count else { return nil } return score * 100 - text.count } /// Entries ranked by `score` against their relative path, best first. public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] { files.compactMap { file in score(query, file.relativePath).map { (file, $0) } } .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath } .prefix(limit) .map(\.0) } } ``` ```swift import Foundation import Observation import OrgIndex import OrgWorkspace /// The folders the user added, the index over them, and the queries the window runs. @MainActor @Observable public final class WorkspaceModel { public private(set) var roots: [WorkspaceRoot] = [] public private(set) var files: [FileEntry] = [] public private(set) var isIndexing = false /// The last error worth showing, such as a folder that can no longer be found. public var lastError: String? /// Bumped when an indexed file changes on disk, with its path. public private(set) var changedFile: (path: String, version: Int)? public let index: IndexStore public let settings: IndexSettings private let rootsFile: URL /// Resolved root folders, keyed by their resolved path, which is how the index stores them. private var resolvedRoots: [String: URL] = [:] @ObservationIgnored private var watcher: AnyObject? private var changeVersion = 0 /// `directory` holds the index and the list of roots; nil keeps both in memory. public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { self.settings = settings if let directory { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path) rootsFile = directory.appendingPathComponent("roots.json") } else { index = try IndexStore() rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json") } if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) { roots = saved } resolveRoots() try reloadFiles() } /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development). public static var defaultDirectory: URL { if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] { return URL(fileURLWithPath: override) } return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar") } // MARK: - Roots public func addRoot(_ url: URL) async throws { let root = try WorkspaceRoot(url: url) guard !roots.contains(where: { $0.path == root.path }) else { return } roots.append(root) try saveRoots() resolveRoots() await refresh() } /// Removes a root by its resolved path, as `FileEntry.root` gives it. public func removeRoot(path: String) throws { let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path) roots.removeAll { bookmarkPaths.contains($0.path) } try saveRoots() resolveRoots() var change = IndexChange() change.removals = files.filter { $0.root == path }.map(\.path) try index.apply(change) try reloadFiles() } private func saveRoots() throws { try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic) } /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found. private func resolveRoots() { resolvedRoots = [:] var refreshed = roots for (i, root) in roots.enumerated() { guard let (url, isStale) = try? root.resolve() else { lastError = "Can't find the folder \(root.path)." continue } if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh } resolvedRoots[url.path] = url } if refreshed != roots { roots = refreshed try? saveRoots() } } public func url(forRoot path: String) -> URL? { resolvedRoots[path] } /// Resolved root paths, sorted. public var rootPaths: [String] { resolvedRoots.keys.sorted() } // MARK: - Indexing /// Reconciles every root off the main thread, then reloads the file list. public func refresh() async { isIndexing = true defer { isIndexing = false } let reconciler = Reconciler(index: index, settings: settings) for url in resolvedRoots.values { do { _ = try await Task.detached { try reconciler.reconcile(root: url) }.value } catch { lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" } } try? reloadFiles() } private func reloadFiles() throws { let roots = Set(resolvedRoots.keys) files = try index.files().compactMap { file in guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil } return FileEntry(path: file.path, root: root, kind: file.kind) } } /// Reconciles the paths a batch of file events named, or whole roots when events were lost. public func filesChanged(_ paths: [URL], rescan roots: [URL]) async { let reconciler = Reconciler(index: index, settings: settings) for rootURL in roots { _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value } for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) { let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") } guard !inRoot.isEmpty else { continue } _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value } try? reloadFiles() for path in paths { changeVersion += 1 changedFile = (path.standardizedFileURL.path, changeVersion) } } #if os(macOS) /// Watches every root; changes reconcile the index and are reported through `changedFile`. public func startWatching() { let roots = Array(resolvedRoots.values) guard !roots.isEmpty else { return } let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in var changed: [URL] = [] var rescans: [URL] = [] for event in events { switch event { case .changed(let url): changed.append(url) case .rescan(let url): rescans.append(url) } } Task { @MainActor [weak self] in guard let self else { return } await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:))) } } fsWatcher.start() watcher = fsWatcher } #endif /// The root an event path belongs to. private func root(containing url: URL) -> URL? { resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) } } /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form. func indexForm(_ url: URL) -> URL { for root in resolvedRoots.values { let real = root.resolvingSymlinksInPath().path if real != root.path, url.path.hasPrefix(real + "/") { return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count)) } } return url } // MARK: - Queries public func tree(forRoot path: String) -> FileTreeNode { FileTreeNode.build(root: path, files: files) } public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] { (try? index.search(query, overlay: overlay)) ?? [] } public func quickOpen(_ query: String) -> [FileEntry] { FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) } } ``` ```swift import Foundation import Observation import OrgCore import OrgDocument import OrgIndex import OrgPresentation /// The file open in a window: its state, its outline, and requests for the editor to show a /// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`. @MainActor @Observable public final class DocumentSession { public private(set) var path: String? public private(set) var document: DocumentState? public private(set) var outline: [OutlineEntry] = [] /// Bumped to ask the editor to reveal an offset. public private(set) var revealRequest: (offset: Int, id: Int)? /// New bytes from disk for the open file, for the editor to merge or reload. public private(set) var diskBytes: (bytes: [UInt8], id: Int)? public var lastError: String? private let fileSystem: FileSystem private var requestID = 0 public init(fileSystem: FileSystem = CoordinatedFileSystem()) { self.fileSystem = fileSystem } public func open(_ path: String) { do { guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { lastError = "\((path as NSString).lastPathComponent) no longer exists." return } let document = DocumentState(bytes: bytes) self.path = path self.document = document outline = Presentation.outline(document.tree) revealRequest = nil diskBytes = nil } catch { lastError = "Can't open \(path): \(error.localizedDescription)" } } /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets /// are used only if the file is unchanged since it was indexed; otherwise the heading is /// found again by position and title. public func show(_ location: HeadingLocation) { if path != location.path { open(location.path) } guard let document else { return } let hash = FileRecord.hash(document.mergeBase) let offset: Int if hash == location.contentHash { offset = location.start } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { offset = outline[location.ordinal].start } else if let entry = outline.first(where: { $0.title == location.title }) { offset = entry.start } else { return } reveal(offset) } public func reveal(_ offset: Int) { requestID += 1 revealRequest = (offset, requestID) } /// Called when the workspace sees the open file change on disk. public func fileChanged(_ path: String) { guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } guard bytes != document?.mergeBase else { return } var updated = document ?? DocumentState(bytes: bytes) _ = updated.diskChanged(to: bytes) document = updated outline = Presentation.outline(updated.tree) requestID += 1 diskBytes = (bytes, requestID) } } ``` ```diff diff --git a/Sources/OrgIndex/IndexStore.swift b/Sources/OrgIndex/IndexStore.swift index 517c833..556ca69 100644 --- a/Sources/OrgIndex/IndexStore.swift +++ b/Sources/OrgIndex/IndexStore.swift @@ -12,12 +12,20 @@ public struct FileState: Sendable, Equatable { /// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to, /// so a caller can tell whether they still apply to an open buffer. -public struct HeadingLocation: Sendable, Equatable { +public struct HeadingLocation: Sendable, Hashable { public let path: String public let ordinal: Int public let title: String public let start: Int public let contentHash: String + + public init(path: String, ordinal: Int, title: String, start: Int, contentHash: String) { + self.path = path + self.ordinal = ordinal + self.title = title + self.start = start + self.contentHash = contentHash + } } /// One reconciliation's worth of changes, applied in a single transaction. ``` - [ ] **Step 3: Run and commit** Run: `swift test --filter OrgAppTests` ```bash git add Package.swift Sources/OrgIndex/IndexStore.swift Sources/OrgApp Tests/OrgAppTests git commit -m "Add workspace and document session models" ``` --- ### Task 3: The app **Files:** - Modify: `Package.swift` (add the `Orgstar` executable), `README.md` - Create: `Sources/Orgstar/OrgstarApp.swift`, `ContentView.swift`, `EditorView.swift`, `DevelopmentHooks.swift`, `scripts/build-app.sh` - [ ] **Step 1: Implement** ```swift import OrgApp import SwiftUI /// Window-level requests the menu bar makes. @MainActor @Observable final class AppCommands { var showingQuickOpen = false var showingFolderPicker = false var focusSearch = false } @main struct OrgstarApp: App { @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } @State private var commands = AppCommands() var body: some Scene { Window("Orgstar", id: "main") { switch workspace { case .success(let model): ContentView(workspace: model, commands: commands) case .failure(let error): ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) } } .defaultSize(width: 1280, height: 860) .commands { CommandGroup(after: .newItem) { Button("Add Folder…") { commands.showingFolderPicker = true } .keyboardShortcut("o", modifiers: [.command, .shift]) Button("Quick Open…") { commands.showingQuickOpen = true } .keyboardShortcut("p") } CommandGroup(after: .textEditing) { Button("Search Notes") { commands.focusSearch = true } .keyboardShortcut("f", modifiers: [.command, .shift]) } } } } ``` ```swift import OrgApp import OrgIndex import SwiftUI struct ContentView: View { let workspace: WorkspaceModel @Bindable var commands: AppCommands @State private var session = DocumentSession() @State private var selectedFile: String? @State private var query = "" @FocusState private var searchFocused: Bool var body: some View { NavigationSplitView { Sidebar(workspace: workspace, selection: $selectedFile) .navigationSplitViewColumnWidth(min: 200, ideal: 260) } content: { Group { if query.isEmpty { OutlineColumn(session: session) } else { SearchResults(results: workspace.search(query)) { location in selectedFile = location.path session.show(location) } } } .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) } detail: { if session.document != nil { EditorView(session: session) } else { ContentUnavailableView( workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", systemImage: "doc.text", description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.") ) } } .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar") .searchable(text: $query, placement: .toolbar, prompt: "Search notes") .searchFocused($searchFocused) .toolbar { if workspace.isIndexing { ProgressView().controlSize(.small) } } .onChange(of: selectedFile) { _, path in if let path, path != session.path { session.open(path) } } .onChange(of: workspace.changedFile?.version) { if let path = workspace.changedFile?.path { session.fileChanged(path) } } .onChange(of: commands.focusSearch) { _, focus in if focus { searchFocused = true commands.focusSearch = false } } .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in guard case .success(let url) = result else { return } Task { do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription } } } .sheet(isPresented: $commands.showingQuickOpen) { QuickOpenView(workspace: workspace) { entry in selectedFile = entry.path commands.showingQuickOpen = false } } .alert("Orgstar", isPresented: errorShown) { Button("OK") { workspace.lastError = nil session.lastError = nil } } message: { Text(workspace.lastError ?? session.lastError ?? "") } .task { DevelopmentHooks.scheduleSnapshot() if let path = DevelopmentHooks.fileToOpen { selectedFile = path } await workspace.refresh() workspace.startWatching() } } private var errorShown: Binding { Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) } } struct Sidebar: View { let workspace: WorkspaceModel @Binding var selection: String? var body: some View { List(selection: $selection) { ForEach(workspace.rootPaths, id: \.self) { root in Section((root as NSString).lastPathComponent) { OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in Label(node.name, systemImage: icon(node)) .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary) .tag(node.file?.path) } } .contextMenu { Button("Remove Folder") { try? workspace.removeRoot(path: root) } } } } .listStyle(.sidebar) } private func icon(_ node: FileTreeNode) -> String { guard let file = node.file else { return "folder" } switch file.kind { case .org: return "doc.text" case .archive: return "archivebox" case .conflict: return "exclamationmark.triangle" } } } struct OutlineColumn: View { let session: DocumentSession var body: some View { List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in Button { session.reveal(entry.start) } label: { Text(entry.title.isEmpty ? "(untitled)" : entry.title) .lineLimit(1) .padding(.leading, CGFloat(entry.level - 1) * 12) } .buttonStyle(.plain) } .overlay { if session.document != nil, session.outline.isEmpty { ContentUnavailableView("No headings", systemImage: "list.bullet.indent") } } } } struct SearchResults: View { let results: [HeadingLocation] let open: (HeadingLocation) -> Void var body: some View { List(results, id: \.self) { location in Button { open(location) } label: { VStack(alignment: .leading, spacing: 2) { Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1) Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary) } } .buttonStyle(.plain) } .overlay { if results.isEmpty { ContentUnavailableView.search } } } } struct QuickOpenView: View { let workspace: WorkspaceModel let open: (FileEntry) -> Void @State private var query = "" @Environment(\.dismiss) private var dismiss var body: some View { let matches = workspace.quickOpen(query) VStack(spacing: 0) { TextField("Open file", text: $query) .textFieldStyle(.plain) .font(.title3) .padding(12) .onSubmit { if let first = matches.first { open(first) } } Divider() List(matches) { entry in Button { open(entry) } label: { VStack(alignment: .leading, spacing: 2) { Text(entry.name) Text(entry.relativePath).font(.caption).foregroundStyle(.secondary) } } .buttonStyle(.plain) } } .frame(width: 520, height: 360) .onExitCommand { dismiss() } } } ``` ```swift import AppKit import OrgApp import OrgEditorAppKit import SwiftUI /// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal /// requests and changes on disk go to the existing one, so folds survive a reload. struct EditorView: NSViewRepresentable { let session: DocumentSession final class Coordinator { var editor: OrgEditor? var path: String? var revealID = 0 var diskID = 0 } func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSView { NSView() } func updateNSView(_ container: NSView, context: Context) { let coordinator = context.coordinator if coordinator.path != session.path { coordinator.path = session.path container.subviews.forEach { $0.removeFromSuperview() } coordinator.editor = nil if let document = session.document { let editor = OrgEditor(document: document, frame: container.bounds, editable: false) let scroll = editor.makeScrollView() scroll.frame = container.bounds scroll.autoresizingMask = [.width, .height] container.addSubview(scroll) container.window?.makeFirstResponder(editor.textView) coordinator.editor = editor } } if let request = session.revealRequest, request.id != coordinator.revealID { coordinator.revealID = request.id coordinator.editor?.reveal(request.offset) } if let disk = session.diskBytes, disk.id != coordinator.diskID { coordinator.diskID = disk.id coordinator.editor?.diskChanged(to: disk.bytes) } } } ``` ```swift import AppKit /// Switches for checking the app from scripts, read from the environment: /// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). /// - `ORGSTAR_OPEN`: a file to select at launch. /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording /// permission. enum DevelopmentHooks { static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } @MainActor static func scheduleSnapshot() { let environment = ProcessInfo.processInfo.environment guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview, let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { view.cacheDisplay(in: view.bounds, to: bitmap) try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) } NSApp.terminate(nil) } } } ``` ```sh #!/bin/sh # Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use. # Usage: scripts/build-app.sh [version] set -eu cd "$(dirname "$0")/.." version="${1:-0.1.0}" app=".build/app/Orgstar.app" swift build -c release --product Orgstar binary="$(swift build -c release --show-bin-path)/Orgstar" rm -rf "$app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "$binary" "$app/Contents/MacOS/Orgstar" cat > "$app/Contents/Info.plist" < CFBundleIdentifier sh.krz.orgstar CFBundleName Orgstar CFBundleDisplayName Orgstar CFBundleExecutable Orgstar CFBundlePackageType APPL CFBundleShortVersionString ${version} CFBundleVersion ${version} LSMinimumSystemVersion 26.0 LSApplicationCategoryType public.app-category.productivity NSHighResolutionCapable NSPrincipalClass NSApplication EOF codesign --force --sign - "$app" echo "$app" ``` - [ ] **Step 2: Build, run and snapshot** Run: `scripts/build-app.sh`, then with a seeded data directory: `ORGSTAR_DATA_DIR= ORGSTAR_OPEN= ORGSTAR_SNAPSHOT=/tmp/shot.png .build/app/Orgstar.app/Contents/MacOS/Orgstar` 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. 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. - [ ] **Step 3: Commit** ```bash git add Package.swift README.md Sources/Orgstar scripts git commit -m "Add the read-only viewer app" ``` --- ### Task 4: Share paragraph styles 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. **Files:** - Modify: `Sources/OrgEditorAppKit/OrgEditor.swift` - [ ] **Step 1: Implement** ```diff @@ -59,6 +59,13 @@ public final class OrgEditor: NSObject { /// passes reach it. Always at a line start. private(set) var styledUpTo = 0 private var stylingScheduled = false + /// One paragraph style object per indent pair, shared by every line that uses it. + private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:] + + private struct IndentKey: Hashable { + let firstLine: Int + let wrapped: Int + } /// Styled before the first render: more than a screenful. static let firstStyleChunk = 30_000 @@ -253,7 +260,7 @@ public final class OrgEditor: NSObject { for run in Presentation.indents(document.tree, in: range) { guard let clipped = clip(run.range, to: range) else { continue } guard run.firstLine == run.wrapped else { - storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) + storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped) continue } // Body lines wrap under their own text: past leading blanks, and past a list bullet @@ -263,7 +270,7 @@ public final class OrgEditor: NSObject { let line = text.lineRange(for: NSRange(location: location, length: 0)) let hanging = Self.hangingColumns(text.substring(with: line)) let lineRange = NSIntersectionRange(line, clipped) - storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange) + storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange) location = NSMaxRange(line) } } @@ -273,6 +280,14 @@ public final class OrgEditor: NSObject { } } + private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle { + let key = IndentKey(firstLine: firstLine, wrapped: wrapped) + if let style = paragraphStyles[key] { return style } + let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped) + paragraphStyles[key] = style + return style + } + private func clip(_ run: Range, to range: Range) -> NSRange? { let clipped = run.clamped(to: range) return clipped.isEmpty ? nil : NSRange(clipped) ``` - [ ] **Step 2: Run the gates; commit** Run: `ORGSTAR_GATES=1 ORGSTAR_GATE_FILE= swift test -c release --filter GateTests` ```bash git add Sources/OrgEditorAppKit/OrgEditor.swift git commit -m "Share paragraph styles across lines" ```