krz/orgstar

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

Commit 923f63b7fc

923f63b7fcb447d5753267b1cf81e58da6449ee6

parent: 2cb9910daf

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:44 UTC

Save from the session

Layout: unified · split

Package.swift +1 −1
@@ -34,6 +34,6 @@ let package = Package(
34 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), 34 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
35 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), 35 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), 36 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]),
37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"]) 37 .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"])
38 ] 38 ]
39) 39)
Sources/OrgApp/DocumentSession.swift +142 −21
@@ -5,53 +5,170 @@ import OrgDocument
5import OrgIndex 5import OrgIndex
6import OrgPresentation 6import OrgPresentation
7 7
8/// The file open in a window: its state, its outline, and requests for the editor to show a 8public enum SaveMode: String, Sendable, CaseIterable {
9/// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`. 9 /// Save shortly after typing stops.
10 case automatic
11 /// Save only on ⌘S.
12 case explicit
13}
14
15/// The file open in a window: its buffer, outline, saving, and requests for the editor to
16/// show a location. The view layer attaches the editor as the buffer and follows
17/// `revealRequest`.
10@MainActor 18@MainActor
11@Observable 19@Observable
12public final class DocumentSession { 20public final class DocumentSession {
13 public private(set) var path: String? 21 public private(set) var path: String?
14 public private(set) var document: DocumentState? 22 /// A copy of the buffer's state as of its last change. Not observed: it changes on every
23 /// keystroke; views follow `path`, `isDirty` and `outline`.
24 @ObservationIgnored public private(set) var document: DocumentState?
15 public private(set) var outline: [OutlineEntry] = [] 25 public private(set) var outline: [OutlineEntry] = []
26 public private(set) var isDirty = false
27 /// Set while the buffer and the file on disk conflict; says how to resolve it.
28 public private(set) var conflict: String?
16 /// Bumped to ask the editor to reveal an offset. 29 /// Bumped to ask the editor to reveal an offset.
17 public private(set) var revealRequest: (offset: Int, id: Int)? 30 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? 31 public var lastError: String?
32 public var saveMode: SaveMode = .automatic
33 public var autosaveDelay: Duration = .seconds(1)
21 34
22 private let fileSystem: FileSystem 35 private let fileSystem: FileSystem
36 private let recovery: RecoveryStore
37 @ObservationIgnored private var buffer: DocumentBuffer?
38 @ObservationIgnored var autosave: Task<Void, Never>?
39 @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
23 private var requestID = 0 40 private var requestID = 0
24 41
25 public init(fileSystem: FileSystem = CoordinatedFileSystem()) { 42 public init(
43 fileSystem: FileSystem = CoordinatedFileSystem(),
44 recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))
45 ) {
26 self.fileSystem = fileSystem 46 self.fileSystem = fileSystem
47 self.recovery = recovery
27 } 48 }
28 49
50 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
51 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
52
29 public func open(_ path: String) { 53 public func open(_ path: String) {
30 do { 54 do {
31 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { 55 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
32 lastError = "\((path as NSString).lastPathComponent) no longer exists." 56 lastError = "\((path as NSString).lastPathComponent) no longer exists."
33 return 57 return
34 } 58 }
35 let document = DocumentState(bytes: bytes) 59 autosave?.cancel()
36 self.path = path 60 self.path = path
37 self.document = document 61 buffer = StateBuffer(DocumentState(bytes: bytes))
38 outline = Presentation.outline(document.tree) 62 conflict = nil
39 revealRequest = nil 63 revealRequest = nil
40 diskBytes = nil 64 refresh()
41 } catch { 65 } catch {
42 lastError = "Can't open \(path): \(error.localizedDescription)" 66 lastError = "Can't open \(path): \(error.localizedDescription)"
43 } 67 }
44 } 68 }
45 69
70 /// Makes `buffer`, built from `document`, the live state of the open file.
71 public func attach(_ buffer: DocumentBuffer) {
72 self.buffer = buffer
73 refresh()
74 }
75
76 /// The buffer changed through typing. The outline follows once typing pauses.
77 public func bufferChanged() {
78 refresh(outline: false)
79 outlineUpdate?.cancel()
80 outlineUpdate = Task { [weak self] in
81 try? await Task.sleep(for: .milliseconds(300))
82 guard !Task.isCancelled, let self, let document = self.document else { return }
83 self.outline = Presentation.outline(document.tree)
84 }
85 scheduleAutosave()
86 }
87
88 private func refresh(outline updateOutline: Bool = true) {
89 guard let buffer else { return }
90 let state = buffer.document
91 document = state
92 if isDirty != state.isDirty { isDirty = state.isDirty }
93 if updateOutline { outline = Presentation.outline(state.tree) }
94 }
95
96 // MARK: - Saving
97
98 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
99
100 private func scheduleAutosave() {
101 autosave?.cancel()
102 guard saveMode == .automatic, isDirty, conflict == nil else { return }
103 autosave = Task { [weak self, autosaveDelay] in
104 try? await Task.sleep(for: autosaveDelay)
105 guard !Task.isCancelled else { return }
106 self?.save()
107 }
108 }
109
110 /// Saves now. True when nothing is left unsaved.
111 @discardableResult
112 public func save() -> Bool {
113 autosave?.cancel()
114 guard let buffer, let url, isDirty else { return !isDirty }
115 guard conflict == nil else { return false }
116 do {
117 let outcome = try buffer.update { try saver.save(&$0, to: url) }
118 switch outcome {
119 case .saved, .mergedAndSaved:
120 break
121 case .conflict:
122 conflict = "\(name) changed on disk, and the changes conflict with yours."
123 case .overwroteExternalChange(let change), .changedAfterWrite(let change):
124 if case .conflict = change {
125 conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
126 }
127 }
128 } catch {
129 lastError = "Can't save \(name): \(error.localizedDescription)"
130 }
131 refresh()
132 return !isDirty
133 }
134
135 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
136 public func keepMine() {
137 guard let buffer, let url else { return }
138 do {
139 try buffer.update { try saver.overwrite(&$0, to: url) }
140 conflict = nil
141 } catch {
142 lastError = "Can't save \(name): \(error.localizedDescription)"
143 }
144 refresh()
145 }
146
147 /// Resolves a conflict by loading the disk version; the buffer goes to recovery.
148 public func useDiskVersion() {
149 guard let buffer, let url else { return }
150 do {
151 guard let bytes = try fileSystem.read(url) else { return }
152 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
153 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
154 conflict = nil
155 } catch {
156 lastError = "Can't read \(name): \(error.localizedDescription)"
157 }
158 refresh()
159 }
160
161 // MARK: - Locations
162
46 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets 163 /// 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 164 /// are used only if the buffer matches what was indexed; otherwise the heading is found
48 /// found again by position and title. 165 /// again by position and title.
49 public func show(_ location: HeadingLocation) { 166 public func show(_ location: HeadingLocation) {
50 if path != location.path { open(location.path) } 167 if path != location.path { open(location.path) }
51 guard let document else { return } 168 guard let document else { return }
52 let hash = FileRecord.hash(document.mergeBase) 169 let hash = FileRecord.hash(document.mergeBase)
53 let offset: Int 170 let offset: Int
54 if hash == location.contentHash { 171 if !isDirty, hash == location.contentHash {
55 offset = location.start 172 offset = location.start
56 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { 173 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
57 offset = outline[location.ordinal].start 174 offset = outline[location.ordinal].start
@@ -70,13 +187,17 @@ public final class DocumentSession {
70 187
71 /// Called when the workspace sees the open file change on disk. 188 /// Called when the workspace sees the open file change on disk.
72 public func fileChanged(_ path: String) { 189 public func fileChanged(_ path: String) {
73 guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } 190 guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
74 guard bytes != document?.mergeBase else { return } 191 guard bytes != buffer.document.mergeBase else { return }
75 var updated = document ?? DocumentState(bytes: bytes) 192 switch buffer.update({ $0.diskChanged(to: bytes) }) {
76 _ = updated.diskChanged(to: bytes) 193 case .conflict:
77 document = updated 194 autosave?.cancel()
78 outline = Presentation.outline(updated.tree) 195 conflict = "\(name) changed on disk, and the changes conflict with yours."
79 requestID += 1 196 case .merged, .reloaded:
80 diskBytes = (bytes, requestID) 197 conflict = nil
198 case .unchanged:
199 break
200 }
201 refresh()
81 } 202 }
82} 203}
Sources/OrgApp/WorkspaceModel.swift +16
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3import OrgDocument
3import OrgIndex 4import OrgIndex
4import OrgWorkspace 5import OrgWorkspace
5 6
@@ -22,6 +23,7 @@ public final class WorkspaceModel {
22 private var resolvedRoots: [String: URL] = [:] 23 private var resolvedRoots: [String: URL] = [:]
23 @ObservationIgnored private var watcher: AnyObject? 24 @ObservationIgnored private var watcher: AnyObject?
24 private var changeVersion = 0 25 private var changeVersion = 0
26 @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)?
25 27
26 /// `directory` holds the index and the list of roots; nil keeps both in memory. 28 /// `directory` holds the index and the list of roots; nil keeps both in memory.
27 public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { 29 public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws {
@@ -192,6 +194,20 @@ public final class WorkspaceModel {
192 (try? index.search(query, overlay: overlay)) ?? [] 194 (try? index.search(query, overlay: overlay)) ?? []
193 } 195 }
194 196
197 /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place
198 /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text
199 /// changes.
200 public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] {
201 guard let path, let document, document.isDirty, let bytes = try? document.encodedText(),
202 let entry = files.first(where: { $0.path == path }) else { return [:] }
203 if let cached = overlayCache, cached.record.path == path, cached.text == document.text {
204 return [path: cached.record]
205 }
206 let record = FileRecord(path: path, root: entry.root, kind: entry.kind, bytes: bytes, mtime: Date().timeIntervalSince1970, settings: settings)
207 overlayCache = (document.text, record)
208 return [path: record]
209 }
210
195 public func quickOpen(_ query: String) -> [FileEntry] { 211 public func quickOpen(_ query: String) -> [FileEntry] {
196 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) 212 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
197 } 213 }
Sources/Orgstar/ContentView.swift +2 −2
@@ -27,7 +27,7 @@ struct ContentView: View {
27 } 27 }
28 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) 28 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
29 } detail: { 29 } detail: {
30 if session.document != nil { 30 if session.path != nil {
31 EditorView(session: session) 31 EditorView(session: session)
32 } else { 32 } else {
33 ContentUnavailableView( 33 ContentUnavailableView(
@@ -135,7 +135,7 @@ struct OutlineColumn: View {
135 .buttonStyle(.plain) 135 .buttonStyle(.plain)
136 } 136 }
137 .overlay { 137 .overlay {
138 if session.document != nil, session.outline.isEmpty { 138 if session.path != nil, session.outline.isEmpty {
139 ContentUnavailableView("No headings", systemImage: "list.bullet.indent") 139 ContentUnavailableView("No headings", systemImage: "list.bullet.indent")
140 } 140 }
141 } 141 }
Sources/Orgstar/EditorView.swift +1 −5
@@ -12,7 +12,6 @@ struct EditorView: NSViewRepresentable {
12 var editor: OrgEditor? 12 var editor: OrgEditor?
13 var path: String? 13 var path: String?
14 var revealID = 0 14 var revealID = 0
15 var diskID = 0
16 } 15 }
17 16
18 func makeCoordinator() -> Coordinator { Coordinator() } 17 func makeCoordinator() -> Coordinator { Coordinator() }
@@ -35,15 +34,12 @@ struct EditorView: NSViewRepresentable {
35 container.addSubview(scroll) 34 container.addSubview(scroll)
36 container.window?.makeFirstResponder(editor.textView) 35 container.window?.makeFirstResponder(editor.textView)
37 coordinator.editor = editor 36 coordinator.editor = editor
37 session.attach(editor)
38 } 38 }
39 } 39 }
40 if let request = session.revealRequest, request.id != coordinator.revealID { 40 if let request = session.revealRequest, request.id != coordinator.revealID {
41 coordinator.revealID = request.id 41 coordinator.revealID = request.id
42 coordinator.editor?.reveal(request.offset) 42 coordinator.editor?.reveal(request.offset)
43 } 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 } 44 }
49} 45}
Tests/OrgAppTests/AppTests.swift +98 −2
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgDocument
3import OrgIndex 4import OrgIndex
4import Testing 5import Testing
5@testable import OrgApp 6@testable import OrgApp
@@ -90,7 +91,7 @@ struct DocumentSessionTests {
90 @Test func openShowAndReload() throws { 91 @Test func openShowAndReload() throws {
91 let notes = try Folder() 92 let notes = try Folder()
92 let file = try notes.write("a.org", "* One\n* Two\n") 93 let file = try notes.write("a.org", "* One\n* Two\n")
93 let session = DocumentSession() 94 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
94 session.open(file.path) 95 session.open(file.path)
95 #expect(session.outline.map(\.title) == ["One", "Two"]) 96 #expect(session.outline.map(\.title) == ["One", "Two"])
96 97
@@ -101,7 +102,6 @@ struct DocumentSessionTests {
101 try notes.write("a.org", "intro\n* One\n* Two\n") 102 try notes.write("a.org", "intro\n* One\n* Two\n")
102 session.fileChanged(file.path) 103 session.fileChanged(file.path)
103 #expect(session.outline.map(\.start) == [6, 12]) 104 #expect(session.outline.map(\.start) == [6, 12])
104 #expect(session.diskBytes != nil)
105 session.show(location) 105 session.show(location)
106 #expect(session.revealRequest?.offset == 12) 106 #expect(session.revealRequest?.offset == 12)
107 } 107 }
@@ -113,3 +113,99 @@ struct DocumentSessionTests {
113 #expect(session.document == nil) 113 #expect(session.document == nil)
114 } 114 }
115} 115}
116
117@MainActor
118struct SessionSavingTests {
119 let notes: Folder
120 let file: URL
121 let recovery: FileRecoveryStore
122 let session: DocumentSession
123
124 init() throws {
125 notes = try Folder()
126 file = try notes.write("a.org", "a\nb\nc\n")
127 recovery = FileRecoveryStore(directory: notes.url.appendingPathComponent(".recovery"))
128 session = DocumentSession(recovery: recovery)
129 session.saveMode = .explicit
130 session.open(file.path)
131 }
132
133 /// Types `text` at `offset`, as the editor would.
134 func type(_ text: String, at offset: Int) throws {
135 let buffer = StateBuffer(session.document!)
136 session.attach(buffer)
137 try buffer.update { try $0.apply([TextEdit(range: offset..<offset, replacement: text)], baseRevision: $0.revision) }
138 session.bufferChanged()
139 }
140
141 var disk: String { (try? String(contentsOf: file, encoding: .utf8)) ?? "" }
142
143 @Test func saveWritesAndClearsDirty() throws {
144 try type("x", at: 0)
145 #expect(session.isDirty)
146 #expect(session.save())
147 #expect(!session.isDirty)
148 #expect(disk == "xa\nb\nc\n")
149 }
150
151 @Test func automaticModeSavesAfterTheDelay() async throws {
152 session.saveMode = .automatic
153 session.autosaveDelay = .milliseconds(10)
154 try type("x", at: 0)
155 #expect(disk == "a\nb\nc\n")
156 await session.autosave?.value
157 #expect(disk == "xa\nb\nc\n")
158 #expect(!session.isDirty)
159 }
160
161 @Test func explicitModeWaitsForSave() throws {
162 try type("x", at: 0)
163 #expect(session.autosave == nil)
164 }
165
166 @Test func externalChangeMergesIntoEdits() throws {
167 try type("x", at: 0)
168 try notes.write("a.org", "a\nb\nC\n")
169 session.fileChanged(file.path)
170 #expect(session.document?.text == "xa\nb\nC\n")
171 #expect(session.conflict == nil)
172 #expect(session.save())
173 #expect(disk == "xa\nb\nC\n")
174 }
175
176 @Test func conflictKeepMine() throws {
177 try type("x", at: 0)
178 try notes.write("a.org", "z\nb\nc\n")
179 session.fileChanged(file.path)
180 #expect(session.conflict != nil)
181 #expect(!session.save())
182 #expect(disk == "z\nb\nc\n")
183 session.keepMine()
184 #expect(session.conflict == nil)
185 #expect(disk == "xa\nb\nc\n")
186 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
187 #expect(kept.contains("z\nb\nc\n"))
188 }
189
190 @Test func conflictUseDiskVersion() throws {
191 try type("x", at: 0)
192 try notes.write("a.org", "z\nb\nc\n")
193 #expect(!session.save())
194 #expect(session.conflict != nil)
195 session.useDiskVersion()
196 #expect(session.conflict == nil)
197 #expect(!session.isDirty)
198 #expect(session.document?.text == "z\nb\nc\n")
199 let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) }
200 #expect(kept.contains("xa\nb\nc\n"))
201 }
202
203 @Test func searchSeesUnsavedHeadings() async throws {
204 let workspace = try WorkspaceModel(directory: nil)
205 try await workspace.addRoot(notes.url)
206 try type("* Unsaved heading\n", at: 0)
207 let overlay = workspace.overlay(path: session.path, document: session.document)
208 #expect(workspace.search("unsaved").isEmpty)
209 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"])
210 }
211}