krz/orgstar

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

Commit 391721e4ef

391721e4ef9b9c89d0ddaed5d5148cdf72417937

parent: 34d9da1754

Verified · cmc

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

Add workspace and document session models

Layout: unified · split

Package.swift +5 −2
@@ -11,7 +11,8 @@ let package = Package(
11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]), 11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]),
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 ], 16 ],
16 dependencies: [ 17 dependencies: [
17 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") 18 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -24,11 +25,13 @@ let package = Package(
24 .target(name: "OrgPresentation", dependencies: ["OrgCore"]), 25 .target(name: "OrgPresentation", dependencies: ["OrgCore"]),
25 .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]), 26 .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]),
26 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), 27 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
28 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]),
27 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 29 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
28 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 30 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
29 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 31 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
30 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), 32 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
31 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), 33 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
32 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]) 34 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]),
35 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"])
33 ] 36 ]
34) 37)
Sources/OrgApp/DocumentSession.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2import Observation
3import OrgCore
4import OrgDocument
5import OrgIndex
6import OrgPresentation
7
8/// The file open in a window: its state, its outline, and requests for the editor to show a
9/// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`.
10@MainActor
11@Observable
12public final class DocumentSession {
13 public private(set) var path: String?
14 public private(set) var document: DocumentState?
15 public private(set) var outline: [OutlineEntry] = []
16 /// Bumped to ask the editor to reveal an offset.
17 public private(set) var revealRequest: (offset: Int, id: Int)?
18 /// New bytes from disk for the open file, for the editor to merge or reload.
19 public private(set) var diskBytes: (bytes: [UInt8], id: Int)?
20 public var lastError: String?
21
22 private let fileSystem: FileSystem
23 private var requestID = 0
24
25 public init(fileSystem: FileSystem = CoordinatedFileSystem()) {
26 self.fileSystem = fileSystem
27 }
28
29 public func open(_ path: String) {
30 do {
31 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
32 lastError = "\((path as NSString).lastPathComponent) no longer exists."
33 return
34 }
35 let document = DocumentState(bytes: bytes)
36 self.path = path
37 self.document = document
38 outline = Presentation.outline(document.tree)
39 revealRequest = nil
40 diskBytes = nil
41 } catch {
42 lastError = "Can't open \(path): \(error.localizedDescription)"
43 }
44 }
45
46 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
47 /// are used only if the file is unchanged since it was indexed; otherwise the heading is
48 /// found again by position and title.
49 public func show(_ location: HeadingLocation) {
50 if path != location.path { open(location.path) }
51 guard let document else { return }
52 let hash = FileRecord.hash(document.mergeBase)
53 let offset: Int
54 if hash == location.contentHash {
55 offset = location.start
56 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
57 offset = outline[location.ordinal].start
58 } else if let entry = outline.first(where: { $0.title == location.title }) {
59 offset = entry.start
60 } else {
61 return
62 }
63 reveal(offset)
64 }
65
66 public func reveal(_ offset: Int) {
67 requestID += 1
68 revealRequest = (offset, requestID)
69 }
70
71 /// Called when the workspace sees the open file change on disk.
72 public func fileChanged(_ path: String) {
73 guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
74 guard bytes != document?.mergeBase else { return }
75 var updated = document ?? DocumentState(bytes: bytes)
76 _ = updated.diskChanged(to: bytes)
77 document = updated
78 outline = Presentation.outline(updated.tree)
79 requestID += 1
80 diskBytes = (bytes, requestID)
81 }
82}
Sources/OrgApp/FileTree.swift added +86
@@ -0,0 +1,86 @@
1import Foundation
2import OrgIndex
3
4/// One file the workspace knows about.
5public struct FileEntry: Sendable, Hashable, Identifiable {
6 public let path: String
7 public let root: String
8 public let kind: FileKind
9
10 public var id: String { path }
11 public var name: String { (path as NSString).lastPathComponent }
12 /// Path below the root, for display and agenda globs.
13 public var relativePath: String {
14 path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : path
15 }
16}
17
18/// A folder or file in the sidebar.
19public struct FileTreeNode: Sendable, Hashable, Identifiable {
20 public let name: String
21 /// The file's or folder's absolute path.
22 public let path: String
23 public let file: FileEntry?
24 /// Nil for files, so a SwiftUI `OutlineGroup` shows no disclosure triangle.
25 public let children: [FileTreeNode]?
26
27 public var id: String { path }
28
29 /// Folders first, then files, each by name. Conflict copies are listed with their files.
30 public static func build(root: String, files: [FileEntry]) -> FileTreeNode {
31 final class Folder {
32 var folders: [String: Folder] = [:]
33 var files: [FileEntry] = []
34 }
35 let top = Folder()
36 for file in files where file.root == root {
37 var folder = top
38 for component in file.relativePath.split(separator: "/").dropLast() {
39 let name = String(component)
40 if folder.folders[name] == nil { folder.folders[name] = Folder() }
41 folder = folder.folders[name]!
42 }
43 folder.files.append(file)
44 }
45 func node(_ folder: Folder, name: String, path: String) -> FileTreeNode {
46 let folders = folder.folders.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }.map {
47 node(folder.folders[$0]!, name: $0, path: path + "/" + $0)
48 }
49 let files = folder.files.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.map {
50 FileTreeNode(name: $0.name, path: $0.path, file: $0, children: nil)
51 }
52 return FileTreeNode(name: name, path: path, file: nil, children: folders + files)
53 }
54 return node(top, name: (root as NSString).lastPathComponent, path: root)
55 }
56}
57
58/// Subsequence matching for quick open: every query character must appear in order. Matches
59/// at word starts and runs of consecutive characters score higher; shorter names win ties.
60public enum FuzzyMatch {
61 public static func score(_ query: String, _ candidate: String) -> Int? {
62 let query = Array(query.lowercased().filter { !$0.isWhitespace })
63 let text = Array(candidate.lowercased())
64 guard !query.isEmpty else { return 0 }
65 var score = 0
66 var q = 0
67 var previousMatch = -2
68 for (i, c) in text.enumerated() where q < query.count && c == query[q] {
69 score += 1
70 if i == previousMatch + 1 { score += 5 }
71 if i == 0 || "/ -_.".contains(text[i - 1]) { score += 8 }
72 previousMatch = i
73 q += 1
74 }
75 guard q == query.count else { return nil }
76 return score * 100 - text.count
77 }
78
79 /// Entries ranked by `score` against their relative path, best first.
80 public static func rank(_ query: String, _ files: [FileEntry], limit: Int = 50) -> [FileEntry] {
81 files.compactMap { file in score(query, file.relativePath).map { (file, $0) } }
82 .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.relativePath < $1.0.relativePath }
83 .prefix(limit)
84 .map(\.0)
85 }
86}
Sources/OrgApp/WorkspaceModel.swift added +198
@@ -0,0 +1,198 @@
1import Foundation
2import Observation
3import OrgIndex
4import OrgWorkspace
5
6/// The folders the user added, the index over them, and the queries the window runs.
7@MainActor
8@Observable
9public final class WorkspaceModel {
10 public private(set) var roots: [WorkspaceRoot] = []
11 public private(set) var files: [FileEntry] = []
12 public private(set) var isIndexing = false
13 /// The last error worth showing, such as a folder that can no longer be found.
14 public var lastError: String?
15 /// Bumped when an indexed file changes on disk, with its path.
16 public private(set) var changedFile: (path: String, version: Int)?
17
18 public let index: IndexStore
19 public let settings: IndexSettings
20 private let rootsFile: URL
21 /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
22 private var resolvedRoots: [String: URL] = [:]
23 @ObservationIgnored private var watcher: AnyObject?
24 private var changeVersion = 0
25
26 /// `directory` holds the index and the list of roots; nil keeps both in memory.
27 public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
28 self.settings = settings
29 if let directory {
30 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
31 index = try IndexStore(path: directory.appendingPathComponent("index.sqlite").path)
32 rootsFile = directory.appendingPathComponent("roots.json")
33 } else {
34 index = try IndexStore()
35 rootsFile = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-roots-\(UUID().uuidString).json")
36 }
37 if let data = try? Data(contentsOf: rootsFile), let saved = try? JSONDecoder().decode([WorkspaceRoot].self, from: data) {
38 roots = saved
39 }
40 resolveRoots()
41 try reloadFiles()
42 }
43
44 /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
45 public static var defaultDirectory: URL {
46 if let override = ProcessInfo.processInfo.environment["ORGSTAR_DATA_DIR"] {
47 return URL(fileURLWithPath: override)
48 }
49 return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
50 }
51
52 // MARK: - Roots
53
54 public func addRoot(_ url: URL) async throws {
55 let root = try WorkspaceRoot(url: url)
56 guard !roots.contains(where: { $0.path == root.path }) else { return }
57 roots.append(root)
58 try saveRoots()
59 resolveRoots()
60 await refresh()
61 }
62
63 /// Removes a root by its resolved path, as `FileEntry.root` gives it.
64 public func removeRoot(path: String) throws {
65 let bookmarkPaths = roots.filter { (try? $0.resolve().url.path) == path || $0.path == path }.map(\.path)
66 roots.removeAll { bookmarkPaths.contains($0.path) }
67 try saveRoots()
68 resolveRoots()
69 var change = IndexChange()
70 change.removals = files.filter { $0.root == path }.map(\.path)
71 try index.apply(change)
72 try reloadFiles()
73 }
74
75 private func saveRoots() throws {
76 try JSONEncoder().encode(roots).write(to: rootsFile, options: .atomic)
77 }
78
79 /// Resolves bookmarks; refreshes stale ones; reports folders that can't be found.
80 private func resolveRoots() {
81 resolvedRoots = [:]
82 var refreshed = roots
83 for (i, root) in roots.enumerated() {
84 guard let (url, isStale) = try? root.resolve() else {
85 lastError = "Can't find the folder \(root.path)."
86 continue
87 }
88 if isStale, let fresh = try? WorkspaceRoot(url: url) { refreshed[i] = fresh }
89 resolvedRoots[url.path] = url
90 }
91 if refreshed != roots {
92 roots = refreshed
93 try? saveRoots()
94 }
95 }
96
97 public func url(forRoot path: String) -> URL? { resolvedRoots[path] }
98
99 /// Resolved root paths, sorted.
100 public var rootPaths: [String] { resolvedRoots.keys.sorted() }
101
102 // MARK: - Indexing
103
104 /// Reconciles every root off the main thread, then reloads the file list.
105 public func refresh() async {
106 isIndexing = true
107 defer { isIndexing = false }
108 let reconciler = Reconciler(index: index, settings: settings)
109 for url in resolvedRoots.values {
110 do {
111 _ = try await Task.detached { try reconciler.reconcile(root: url) }.value
112 } catch {
113 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
114 }
115 }
116 try? reloadFiles()
117 }
118
119 private func reloadFiles() throws {
120 let roots = Set(resolvedRoots.keys)
121 files = try index.files().compactMap { file in
122 guard let root = roots.first(where: { file.path.hasPrefix($0 + "/") }) else { return nil }
123 return FileEntry(path: file.path, root: root, kind: file.kind)
124 }
125 }
126
127 /// Reconciles the paths a batch of file events named, or whole roots when events were lost.
128 public func filesChanged(_ paths: [URL], rescan roots: [URL]) async {
129 let reconciler = Reconciler(index: index, settings: settings)
130 for rootURL in roots {
131 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL) }.value
132 }
133 for (rootPath, rootURL) in resolvedRoots where !roots.contains(rootURL) {
134 let inRoot = paths.filter { $0.path.hasPrefix(rootPath + "/") }
135 guard !inRoot.isEmpty else { continue }
136 _ = try? await Task.detached { try reconciler.reconcile(root: rootURL, paths: inRoot) }.value
137 }
138 try? reloadFiles()
139 for path in paths {
140 changeVersion += 1
141 changedFile = (path.standardizedFileURL.path, changeVersion)
142 }
143 }
144
145 #if os(macOS)
146 /// Watches every root; changes reconcile the index and are reported through `changedFile`.
147 public func startWatching() {
148 let roots = Array(resolvedRoots.values)
149 guard !roots.isEmpty else { return }
150 let fsWatcher = FSEventsWatcher(roots: roots.map { $0.resolvingSymlinksInPath() }) { [weak self] events in
151 var changed: [URL] = []
152 var rescans: [URL] = []
153 for event in events {
154 switch event {
155 case .changed(let url): changed.append(url)
156 case .rescan(let url): rescans.append(url)
157 }
158 }
159 Task { @MainActor [weak self] in
160 guard let self else { return }
161 await self.filesChanged(changed.map(self.indexForm), rescan: rescans.compactMap(self.root(containing:)))
162 }
163 }
164 fsWatcher.start()
165 watcher = fsWatcher
166 }
167 #endif
168
169 /// The root an event path belongs to.
170 private func root(containing url: URL) -> URL? {
171 resolvedRoots.values.first { url.path.hasPrefix($0.resolvingSymlinksInPath().path) || url.path.hasPrefix($0.path) }
172 }
173
174 /// FSEvents reports real paths (`/private/var/...`); the index uses the root's own form.
175 func indexForm(_ url: URL) -> URL {
176 for root in resolvedRoots.values {
177 let real = root.resolvingSymlinksInPath().path
178 if real != root.path, url.path.hasPrefix(real + "/") {
179 return URL(fileURLWithPath: root.path + url.path.dropFirst(real.count))
180 }
181 }
182 return url
183 }
184
185 // MARK: - Queries
186
187 public func tree(forRoot path: String) -> FileTreeNode {
188 FileTreeNode.build(root: path, files: files)
189 }
190
191 public func search(_ query: String, overlay: [String: FileRecord] = [:]) -> [HeadingLocation] {
192 (try? index.search(query, overlay: overlay)) ?? []
193 }
194
195 public func quickOpen(_ query: String) -> [FileEntry] {
196 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
197 }
198}
Sources/OrgIndex/IndexStore.swift +9 −1
@@ -12,12 +12,20 @@ public struct FileState: Sendable, Equatable {
12 12
13/// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to, 13/// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to,
14/// so a caller can tell whether they still apply to an open buffer. 14/// so a caller can tell whether they still apply to an open buffer.
15public struct HeadingLocation: Sendable, Equatable { 15public struct HeadingLocation: Sendable, Hashable {
16 public let path: String 16 public let path: String
17 public let ordinal: Int 17 public let ordinal: Int
18 public let title: String 18 public let title: String
19 public let start: Int 19 public let start: Int
20 public let contentHash: String 20 public let contentHash: String
21
22 public init(path: String, ordinal: Int, title: String, start: Int, contentHash: String) {
23 self.path = path
24 self.ordinal = ordinal
25 self.title = title
26 self.start = start
27 self.contentHash = contentHash
28 }
21} 29}
22 30
23/// One reconciliation's worth of changes, applied in a single transaction. 31/// One reconciliation's worth of changes, applied in a single transaction.
Tests/OrgAppTests/AppTests.swift added +115
@@ -0,0 +1,115 @@
1import Foundation
2import OrgCore
3import OrgIndex
4import Testing
5@testable import OrgApp
6
7/// A temporary folder that removes itself.
8final class Folder {
9 let url: URL
10
11 init() throws {
12 url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
13 try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
14 }
15
16 deinit {
17 try? FileManager.default.removeItem(at: url)
18 }
19
20 @discardableResult
21 func write(_ path: String, _ text: String) throws -> URL {
22 let file = url.appendingPathComponent(path)
23 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
24 try Data(text.utf8).write(to: file)
25 return file.standardizedFileURL
26 }
27}
28
29struct FileTreeTests {
30 @Test func foldersFirstThenFiles() {
31 let files = ["b.org", "a/z.org", "a/y.org", "c.sync-conflict-1.org", "a2.org"].map {
32 FileEntry(path: "/r/" + $0, root: "/r", kind: $0.contains("conflict") ? .conflict : .org)
33 }
34 let tree = FileTreeNode.build(root: "/r", files: files)
35 #expect(tree.children?.map(\.name) == ["a", "a2.org", "b.org", "c.sync-conflict-1.org"])
36 #expect(tree.children?[0].children?.map(\.name) == ["y.org", "z.org"])
37 #expect(tree.children?[1].children == nil)
38 }
39
40 @Test func fuzzyMatching() {
41 #expect(FuzzyMatch.score("wn", "work/notes.org") != nil)
42 #expect(FuzzyMatch.score("xyz", "work/notes.org") == nil)
43 let files = ["work/notes.org", "notes.org", "personal/wine.org"].map { FileEntry(path: "/r/" + $0, root: "/r", kind: .org) }
44 #expect(FuzzyMatch.rank("notes", files).map(\.relativePath) == ["notes.org", "work/notes.org"])
45 #expect(FuzzyMatch.rank("", files).count == 3)
46 }
47}
48
49@MainActor
50struct WorkspaceModelTests {
51 @Test func addSearchRemoveAndPersist() async throws {
52 let notes = try Folder()
53 let support = try Folder()
54 try notes.write("a.org", "* Groceries\nbuy apples\n")
55 try notes.write("sub/b.org", "* Taxes\n")
56 try notes.write("c.sync-conflict-1.org", "* Taxes copy\n")
57
58 let model = try WorkspaceModel(directory: support.url)
59 try await model.addRoot(notes.url)
60 #expect(model.files.count == 3)
61 #expect(model.search("apples").map(\.title) == ["Groceries"])
62 #expect(model.quickOpen("bo").map(\.name) == ["b.org"])
63 let root = try #require(model.rootPaths.first)
64 #expect(model.tree(forRoot: root).children?.map(\.name) == ["sub", "a.org", "c.sync-conflict-1.org"])
65
66 let reopened = try WorkspaceModel(directory: support.url)
67 #expect(reopened.roots.count == 1)
68 #expect(reopened.files.count == 3)
69
70 try reopened.removeRoot(path: root)
71 #expect(reopened.roots.isEmpty)
72 #expect(reopened.files.isEmpty)
73 #expect(reopened.search("apples").isEmpty)
74 }
75
76 @Test func changedFilesAreReindexed() async throws {
77 let notes = try Folder()
78 let file = try notes.write("a.org", "* Old\n")
79 let model = try WorkspaceModel(directory: nil)
80 try await model.addRoot(notes.url)
81 try notes.write("a.org", "* New\n")
82 await model.filesChanged([file], rescan: [])
83 #expect(model.search("new").count == 1)
84 #expect(model.changedFile?.path == file.path)
85 }
86}
87
88@MainActor
89struct DocumentSessionTests {
90 @Test func openShowAndReload() throws {
91 let notes = try Folder()
92 let file = try notes.write("a.org", "* One\n* Two\n")
93 let session = DocumentSession()
94 session.open(file.path)
95 #expect(session.outline.map(\.title) == ["One", "Two"])
96
97 let location = HeadingLocation(path: file.path, ordinal: 1, title: "Two", start: 6, contentHash: "stale")
98 session.show(location)
99 #expect(session.revealRequest?.offset == 6)
100
101 try notes.write("a.org", "intro\n* One\n* Two\n")
102 session.fileChanged(file.path)
103 #expect(session.outline.map(\.start) == [6, 12])
104 #expect(session.diskBytes != nil)
105 session.show(location)
106 #expect(session.revealRequest?.offset == 12)
107 }
108
109 @Test func missingFileReportsAnError() {
110 let session = DocumentSession()
111 session.open("/nonexistent/a.org")
112 #expect(session.lastError != nil)
113 #expect(session.document == nil)
114 }
115}