Commit ef12a61bb3
Verified · cmc
Layout: unified · split
Package.swift +3 −1
| @@ -12,7 +12,8 @@ let package = Package( | |||
| 12 | .library(name: "OrgPresentation", targets: ["OrgPresentation"]), | 12 | .library(name: "OrgPresentation", targets: ["OrgPresentation"]), |
| 13 | .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]), | 13 | .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]), |
| 14 | .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]), | 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 | dependencies: [ | 18 | dependencies: [ |
| 18 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") | 19 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") |
| @@ -26,6 +27,7 @@ let package = Package( | |||
| 26 | .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]), | 27 | .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]), |
| 27 | .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), | 28 | .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), |
| 28 | .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), | 29 | .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), |
| 30 | .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit"]), | ||
| 29 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), | 31 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 30 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), | 32 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 31 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), | 33 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
README.md +20
| @@ -8,4 +8,24 @@ the same folder. | |||
| 8 | 8 | ||
| 9 | Status: phase 1 (parser, workspace, read-only viewer). | 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 | License: 0BSD. | 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" | ||