docs/plans/2026-10-04-viewer.md
1684 lines · 69681 bytes
8 symbols in this file
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. OrgAppandOrgPresentationimport 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=<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.
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 --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
import OrgCore
/// Which headings are folded: whole subtrees, or only their own bodies.
public struct FoldState: Sendable, Equatable {
public var folds: Set<Int>
public var bodyFolds: Set<Int>
public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
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 --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<Int>]
- /// Start offsets of folded headings.
+ /// Start offsets of headings whose whole subtree is folded.
public var folds: Set<Int>
+ /// Start offsets of headings whose own body is folded; their child headings stay visible.
+ public var bodyFolds: Set<Int>
- public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
+ public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
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)..<mapOffset($0.upperBound, sorted) },
- folds: Set(folds.map { mapOffset($0, sorted) })
+ folds: Set(folds.map { mapOffset($0, sorted) }),
+ bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
)
}
/// Drops folds that no longer sit at the start of a heading.
public func pruned(to tree: OrgTree) -> 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<Int>) {
- 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..<end` or starts right after it, where joining
/// lines could take it away.
static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> 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<Int>) -> [Range<Int>] {
- 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<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
+ guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
var ranges: [Range<Int>] = []
- collectHidden(tree.root, folds, &ranges)
+ collectHidden(tree.root, folds, bodyFolds, &ranges)
return ranges
}
- private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
+ private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
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..<section.range.upperBound)
}
- } else {
- collectHidden(section, folds, &ranges)
+ continue
}
+ if bodyFolds.contains(start) {
+ let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
+ if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) }
+ }
+ collectHidden(section, folds, bodyFolds, &ranges)
}
}
- Step 3: Run and commit
Run: swift test
git add Sources/OrgPresentation Sources/OrgDocument/ViewState.swift Sources/OrgEditorAppKit Tests/OrgPresentationTests Tests/OrgEditorAppKitTests/EditorTests.swift
git commit -m "Cycle folds, apply #+STARTUP visibility and indent wrapped lines"
Task 2: App model
Files:
-
Modify:
Package.swift(addOrgApp),Sources/OrgIndex/IndexStore.swift(HeadingLocationgets a public init andHashable) -
Create:
Sources/OrgApp/FileTree.swift,Sources/OrgApp/WorkspaceModel.swift,Sources/OrgApp/DocumentSession.swift -
Test:
Tests/OrgAppTests/AppTests.swift -
Step 1: Write the failing tests
import Foundation
import OrgCore
import OrgIndex
import Testing
@testable import OrgApp
/// A temporary folder that removes itself.
final class Folder {
let url: URL
init() throws {
url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
deinit {
try? FileManager.default.removeItem(at: url)
}
@discardableResult
func write(_ path: String, _ text: String) throws -> 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
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)
}
}
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 })
}
}
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 --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
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 theOrgstarexecutable),README.md -
Create:
Sources/Orgstar/OrgstarApp.swift,ContentView.swift,EditorView.swift,DevelopmentHooks.swift,scripts/build-app.sh -
Step 1: Implement
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])
}
}
}
}
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<Bool> {
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() }
}
}
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)
}
}
}
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)
}
}
}
#!/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>sh.krz.orgstar</string>
<key>CFBundleName</key>
<string>Orgstar</string>
<key>CFBundleDisplayName</key>
<string>Orgstar</string>
<key>CFBundleExecutable</key>
<string>Orgstar</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${version}</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
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=<dir> ORGSTAR_OPEN=<file.org> 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
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
@@ -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<Int>, to range: Range<Int>) -> 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=<large real file> swift test -c release --filter GateTests
git add Sources/OrgEditorAppKit/OrgEditor.swift
git commit -m "Share paragraph styles across lines"