Commit ef12a61bb3
Verified · cmc
Layout: unified · split
Package.swift +3 −1
| @@ -12,7 +12,8 @@ let package = Package( | ||
| 12 | 12 | .library(name: "OrgPresentation", targets: ["OrgPresentation"]), |
| 13 | 13 | .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]), |
| 14 | 14 | .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]), |
| 15 | .library(name: "OrgApp", targets: ["OrgApp"]) | |
| 15 | .library(name: "OrgApp", targets: ["OrgApp"]), | |
| 16 | .executable(name: "Orgstar", targets: ["Orgstar"]) | |
| 16 | 17 | ], |
| 17 | 18 | dependencies: [ |
| 18 | 19 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") |
| @@ -26,6 +27,7 @@ let package = Package( | ||
| 26 | 27 | .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]), |
| 27 | 28 | .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), |
| 28 | 29 | .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), |
| 30 | .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit"]), | |
| 29 | 31 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 30 | 32 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 31 | 33 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
README.md +20
| @@ -8,4 +8,24 @@ the same folder. | ||
| 8 | 8 | |
| 9 | 9 | Status: phase 1 (parser, workspace, read-only viewer). |
| 10 | 10 | |
| 11 | ## Build and run | |
| 12 | ||
| 13 | Requires macOS 26 and Xcode 27 (Swift 6.2 tools). | |
| 14 | ||
| 15 | ```sh | |
| 16 | scripts/build-app.sh # builds .build/app/Orgstar.app, signed ad hoc | |
| 17 | open .build/app/Orgstar.app | |
| 18 | ``` | |
| 19 | ||
| 20 | Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F | |
| 21 | searches. In the editor, TAB on a heading cycles its folding and Shift-TAB | |
| 22 | cycles the whole file. | |
| 23 | ||
| 24 | ```sh | |
| 25 | swift test # all tests | |
| 26 | ORGSTAR_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 | ||
| 11 | 31 | License: 0BSD. |
Sources/Orgstar/ContentView.swift added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | import OrgApp | |
| 2 | import OrgIndex | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | struct 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 | ||
| 91 | struct 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 | ||
| 123 | struct 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 | ||
| 145 | struct 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 | ||
| 167 | struct 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 @@ | ||
| 1 | import 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. | |
| 9 | enum 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 @@ | ||
| 1 | import AppKit | |
| 2 | import OrgApp | |
| 3 | import OrgEditorAppKit | |
| 4 | import 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. | |
| 8 | struct 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 @@ | ||
| 1 | import OrgApp | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Window-level requests the menu bar makes. | |
| 5 | @MainActor | |
| 6 | @Observable | |
| 7 | final class AppCommands { | |
| 8 | var showingQuickOpen = false | |
| 9 | var showingFolderPicker = false | |
| 10 | var focusSearch = false | |
| 11 | } | |
| 12 | ||
| 13 | @main | |
| 14 | struct 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] | |
| 4 | set -eu | |
| 5 | ||
| 6 | cd "$(dirname "$0")/.." | |
| 7 | version="${1:-0.1.0}" | |
| 8 | app=".build/app/Orgstar.app" | |
| 9 | ||
| 10 | swift build -c release --product Orgstar | |
| 11 | binary="$(swift build -c release --show-bin-path)/Orgstar" | |
| 12 | ||
| 13 | rm -rf "$app" | |
| 14 | mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" | |
| 15 | cp "$binary" "$app/Contents/MacOS/Orgstar" | |
| 16 | ||
| 17 | cat > "$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> | |
| 46 | EOF | |
| 47 | ||
| 48 | codesign --force --sign - "$app" | |
| 49 | echo "$app" | |