krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit ef12a61bb3

ef12a61bb325e9e2127b71376fc4bd6410bf41cb

parent: 391721e4ef

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:54 UTC

Add the read-only viewer app

Layout: unified · split

Package.swift +3 −1
@@ -12,7 +12,8 @@ let package = Package(
1212 .library(name: "OrgPresentation", targets: ["OrgPresentation"]),
1313 .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]),
1414 .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]),
15 .library(name: "OrgApp", targets: ["OrgApp"])
15 .library(name: "OrgApp", targets: ["OrgApp"]),
16 .executable(name: "Orgstar", targets: ["Orgstar"])
1617 ],
1718 dependencies: [
1819 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -26,6 +27,7 @@ let package = Package(
2627 .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]),
2728 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
2829 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]),
30 .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit"]),
2931 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
3032 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
3133 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
README.md +20
@@ -8,4 +8,24 @@ the same folder.
88
99Status: phase 1 (parser, workspace, read-only viewer).
1010
11## Build and run
12
13Requires macOS 26 and Xcode 27 (Swift 6.2 tools).
14
15```sh
16scripts/build-app.sh # builds .build/app/Orgstar.app, signed ad hoc
17open .build/app/Orgstar.app
18```
19
20Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F
21searches. In the editor, TAB on a heading cycles its folding and Shift-TAB
22cycles the whole file.
23
24```sh
25swift test # all tests
26ORGSTAR_GATES=1 swift test -c release --filter GateTests # performance gates
27```
28
29`docs/design.md` is the design; `docs/plans/` has one plan per piece of work.
30
1131License: 0BSD.
Sources/Orgstar/ContentView.swift added +197
@@ -0,0 +1,197 @@
1import OrgApp
2import OrgIndex
3import SwiftUI
4
5struct ContentView: View {
6 let workspace: WorkspaceModel
7 @Bindable var commands: AppCommands
8 @State private var session = DocumentSession()
9 @State private var selectedFile: String?
10 @State private var query = ""
11 @FocusState private var searchFocused: Bool
12
13 var body: some View {
14 NavigationSplitView {
15 Sidebar(workspace: workspace, selection: $selectedFile)
16 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
17 } content: {
18 Group {
19 if query.isEmpty {
20 OutlineColumn(session: session)
21 } else {
22 SearchResults(results: workspace.search(query)) { location in
23 selectedFile = location.path
24 session.show(location)
25 }
26 }
27 }
28 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
29 } detail: {
30 if session.document != nil {
31 EditorView(session: session)
32 } else {
33 ContentUnavailableView(
34 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
35 systemImage: "doc.text",
36 description: Text(workspace.roots.isEmpty ? "File ▸ Add Folder… (⇧⌘O)" : "Or press ⌘P to open one by name.")
37 )
38 }
39 }
40 .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
41 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
42 .searchFocused($searchFocused)
43 .toolbar {
44 if workspace.isIndexing { ProgressView().controlSize(.small) }
45 }
46 .onChange(of: selectedFile) { _, path in
47 if let path, path != session.path { session.open(path) }
48 }
49 .onChange(of: workspace.changedFile?.version) {
50 if let path = workspace.changedFile?.path { session.fileChanged(path) }
51 }
52 .onChange(of: commands.focusSearch) { _, focus in
53 if focus {
54 searchFocused = true
55 commands.focusSearch = false
56 }
57 }
58 .fileImporter(isPresented: $commands.showingFolderPicker, allowedContentTypes: [.folder]) { result in
59 guard case .success(let url) = result else { return }
60 Task {
61 do { try await workspace.addRoot(url) } catch { workspace.lastError = error.localizedDescription }
62 }
63 }
64 .sheet(isPresented: $commands.showingQuickOpen) {
65 QuickOpenView(workspace: workspace) { entry in
66 selectedFile = entry.path
67 commands.showingQuickOpen = false
68 }
69 }
70 .alert("Orgstar", isPresented: errorShown) {
71 Button("OK") {
72 workspace.lastError = nil
73 session.lastError = nil
74 }
75 } message: {
76 Text(workspace.lastError ?? session.lastError ?? "")
77 }
78 .task {
79 DevelopmentHooks.scheduleSnapshot()
80 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
81 await workspace.refresh()
82 workspace.startWatching()
83 }
84 }
85
86 private var errorShown: Binding<Bool> {
87 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
88 }
89}
90
91struct Sidebar: View {
92 let workspace: WorkspaceModel
93 @Binding var selection: String?
94
95 var body: some View {
96 List(selection: $selection) {
97 ForEach(workspace.rootPaths, id: \.self) { root in
98 Section((root as NSString).lastPathComponent) {
99 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
100 Label(node.name, systemImage: icon(node))
101 .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
102 .tag(node.file?.path)
103 }
104 }
105 .contextMenu {
106 Button("Remove Folder") { try? workspace.removeRoot(path: root) }
107 }
108 }
109 }
110 .listStyle(.sidebar)
111 }
112
113 private func icon(_ node: FileTreeNode) -> String {
114 guard let file = node.file else { return "folder" }
115 switch file.kind {
116 case .org: return "doc.text"
117 case .archive: return "archivebox"
118 case .conflict: return "exclamationmark.triangle"
119 }
120 }
121}
122
123struct OutlineColumn: View {
124 let session: DocumentSession
125
126 var body: some View {
127 List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
128 Button {
129 session.reveal(entry.start)
130 } label: {
131 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
132 .lineLimit(1)
133 .padding(.leading, CGFloat(entry.level - 1) * 12)
134 }
135 .buttonStyle(.plain)
136 }
137 .overlay {
138 if session.document != nil, session.outline.isEmpty {
139 ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
140 }
141 }
142 }
143}
144
145struct SearchResults: View {
146 let results: [HeadingLocation]
147 let open: (HeadingLocation) -> Void
148
149 var body: some View {
150 List(results, id: \.self) { location in
151 Button {
152 open(location)
153 } label: {
154 VStack(alignment: .leading, spacing: 2) {
155 Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(1)
156 Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
157 }
158 }
159 .buttonStyle(.plain)
160 }
161 .overlay {
162 if results.isEmpty { ContentUnavailableView.search }
163 }
164 }
165}
166
167struct QuickOpenView: View {
168 let workspace: WorkspaceModel
169 let open: (FileEntry) -> Void
170 @State private var query = ""
171 @Environment(\.dismiss) private var dismiss
172
173 var body: some View {
174 let matches = workspace.quickOpen(query)
175 VStack(spacing: 0) {
176 TextField("Open file", text: $query)
177 .textFieldStyle(.plain)
178 .font(.title3)
179 .padding(12)
180 .onSubmit { if let first = matches.first { open(first) } }
181 Divider()
182 List(matches) { entry in
183 Button {
184 open(entry)
185 } label: {
186 VStack(alignment: .leading, spacing: 2) {
187 Text(entry.name)
188 Text(entry.relativePath).font(.caption).foregroundStyle(.secondary)
189 }
190 }
191 .buttonStyle(.plain)
192 }
193 }
194 .frame(width: 520, height: 360)
195 .onExitCommand { dismiss() }
196 }
197}
Sources/Orgstar/DevelopmentHooks.swift added +26
@@ -0,0 +1,26 @@
1import AppKit
2
3/// Switches for checking the app from scripts, read from the environment:
4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
5/// - `ORGSTAR_OPEN`: a file to select at launch.
6/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
7/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
8/// permission.
9enum DevelopmentHooks {
10 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
11
12 @MainActor
13 static func scheduleSnapshot() {
14 let environment = ProcessInfo.processInfo.environment
15 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
16 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
17 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
18 if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview,
19 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
20 view.cacheDisplay(in: view.bounds, to: bitmap)
21 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
22 }
23 NSApp.terminate(nil)
24 }
25 }
26}
Sources/Orgstar/EditorView.swift added +49
@@ -0,0 +1,49 @@
1import AppKit
2import OrgApp
3import OrgEditorAppKit
4import SwiftUI
5
6/// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal
7/// requests and changes on disk go to the existing one, so folds survive a reload.
8struct EditorView: NSViewRepresentable {
9 let session: DocumentSession
10
11 final class Coordinator {
12 var editor: OrgEditor?
13 var path: String?
14 var revealID = 0
15 var diskID = 0
16 }
17
18 func makeCoordinator() -> Coordinator { Coordinator() }
19
20 func makeNSView(context: Context) -> NSView {
21 NSView()
22 }
23
24 func updateNSView(_ container: NSView, context: Context) {
25 let coordinator = context.coordinator
26 if coordinator.path != session.path {
27 coordinator.path = session.path
28 container.subviews.forEach { $0.removeFromSuperview() }
29 coordinator.editor = nil
30 if let document = session.document {
31 let editor = OrgEditor(document: document, frame: container.bounds, editable: false)
32 let scroll = editor.makeScrollView()
33 scroll.frame = container.bounds
34 scroll.autoresizingMask = [.width, .height]
35 container.addSubview(scroll)
36 container.window?.makeFirstResponder(editor.textView)
37 coordinator.editor = editor
38 }
39 }
40 if let request = session.revealRequest, request.id != coordinator.revealID {
41 coordinator.revealID = request.id
42 coordinator.editor?.reveal(request.offset)
43 }
44 if let disk = session.diskBytes, disk.id != coordinator.diskID {
45 coordinator.diskID = disk.id
46 coordinator.editor?.diskChanged(to: disk.bytes)
47 }
48 }
49}
Sources/Orgstar/OrgstarApp.swift added +41
@@ -0,0 +1,41 @@
1import OrgApp
2import SwiftUI
3
4/// Window-level requests the menu bar makes.
5@MainActor
6@Observable
7final class AppCommands {
8 var showingQuickOpen = false
9 var showingFolderPicker = false
10 var focusSearch = false
11}
12
13@main
14struct OrgstarApp: App {
15 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
16 @State private var commands = AppCommands()
17
18 var body: some Scene {
19 Window("Orgstar", id: "main") {
20 switch workspace {
21 case .success(let model):
22 ContentView(workspace: model, commands: commands)
23 case .failure(let error):
24 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
25 }
26 }
27 .defaultSize(width: 1280, height: 860)
28 .commands {
29 CommandGroup(after: .newItem) {
30 Button("Add Folder…") { commands.showingFolderPicker = true }
31 .keyboardShortcut("o", modifiers: [.command, .shift])
32 Button("Quick Open…") { commands.showingQuickOpen = true }
33 .keyboardShortcut("p")
34 }
35 CommandGroup(after: .textEditing) {
36 Button("Search Notes") { commands.focusSearch = true }
37 .keyboardShortcut("f", modifiers: [.command, .shift])
38 }
39 }
40 }
41}
scripts/build-app.sh added +49
@@ -0,0 +1,49 @@
1#!/bin/sh
2# Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use.
3# Usage: scripts/build-app.sh [version]
4set -eu
5
6cd "$(dirname "$0")/.."
7version="${1:-0.1.0}"
8app=".build/app/Orgstar.app"
9
10swift build -c release --product Orgstar
11binary="$(swift build -c release --show-bin-path)/Orgstar"
12
13rm -rf "$app"
14mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
15cp "$binary" "$app/Contents/MacOS/Orgstar"
16
17cat > "$app/Contents/Info.plist" <<EOF
18<?xml version="1.0" encoding="UTF-8"?>
19<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
20<plist version="1.0">
21<dict>
22 <key>CFBundleIdentifier</key>
23 <string>sh.krz.orgstar</string>
24 <key>CFBundleName</key>
25 <string>Orgstar</string>
26 <key>CFBundleDisplayName</key>
27 <string>Orgstar</string>
28 <key>CFBundleExecutable</key>
29 <string>Orgstar</string>
30 <key>CFBundlePackageType</key>
31 <string>APPL</string>
32 <key>CFBundleShortVersionString</key>
33 <string>${version}</string>
34 <key>CFBundleVersion</key>
35 <string>${version}</string>
36 <key>LSMinimumSystemVersion</key>
37 <string>26.0</string>
38 <key>LSApplicationCategoryType</key>
39 <string>public.app-category.productivity</string>
40 <key>NSHighResolutionCapable</key>
41 <true/>
42 <key>NSPrincipalClass</key>
43 <string>NSApplication</string>
44</dict>
45</plist>
46EOF
47
48codesign --force --sign - "$app"
49echo "$app"