krz/orgstar

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

Commit 2cb9910daf

2cb9910daf496bc213dbf10762f4e94f28afce82

parent: 1f40d56d3e

Verified · cmc

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

Add document buffers

Layout: unified · split

Sources/OrgDocument/Buffer.swift added +24
@@ -0,0 +1,24 @@
1import Foundation
2
3/// Where an open document's live state is kept: the editor while one shows it, a
4/// `StateBuffer` before that.
5@MainActor
6public protocol DocumentBuffer: AnyObject {
7 var document: DocumentState { get }
8 /// Applies a change made outside typing (save, disk change, revert) and updates whatever
9 /// shows the document.
10 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T
11}
12
13@MainActor
14public final class StateBuffer: DocumentBuffer {
15 public private(set) var document: DocumentState
16
17 public init(_ document: DocumentState) {
18 self.document = document
19 }
20
21 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
22 try change(&document)
23 }
24}
Sources/OrgDocument/Saving.swift +10
@@ -78,4 +78,14 @@ public struct Saver: Sendable {
78 } 78 }
79 throw SaveError.fileKeepsChanging 79 throw SaveError.fileKeepsChanging
80 } 80 }
81
82 /// Writes the buffer over whatever is on disk, for keeping our version over a conflicting
83 /// one. The replaced version goes to recovery unless it is the merge base.
84 public func overwrite(_ state: inout DocumentState, to url: URL) throws {
85 let bytes = try state.encodedText()
86 if let replaced = try fileSystem.replace(url, with: bytes), replaced != state.mergeBase {
87 try recovery.keep(replaced, for: url, label: "external")
88 }
89 state.didWrite(bytes)
90 }
81} 91}
Sources/OrgEditorAppKit/OrgEditor.swift +34 −20
@@ -153,21 +153,15 @@ public final class OrgEditor: NSObject {
153 153
154 // MARK: - Saving 154 // MARK: - Saving
155 155
156 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and 156 /// Saves through `saver`. A merge that changes the buffer reloads the text view.
157 /// folds are mapped through the change.
158 @discardableResult 157 @discardableResult
159 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome { 158 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
160 let before = document.text 159 try update { try saver.save(&$0, to: url) }
161 let outcome = try saver.save(&document, to: url)
162 if document.text != before {
163 let edits = lineEdits(from: before, to: document.text)
164 load(document.text)
165 let mapped = view.mapped(through: edits)
166 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
167 }
168 return outcome
169 } 160 }
170 161
162 /// Called after every change to the document, typed or not.
163 public var onChange: (() -> Void)?
164
171 // MARK: - Commands 165 // MARK: - Commands
172 166
173 /// Called with messages commands report, such as why one couldn't run here. 167 /// Called with messages commands report, such as why one couldn't run here.
@@ -219,15 +213,9 @@ public final class OrgEditor: NSObject {
219 213
220 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept 214 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
221 /// its headings. 215 /// its headings.
222 public func diskChanged(to bytes: [UInt8]) { 216 @discardableResult
223 let edits: [TextEdit] 217 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
224 switch document.diskChanged(to: bytes) { 218 update { $0.diskChanged(to: bytes) }
225 case .reloaded(let changes), .merged(let changes): edits = changes
226 case .unchanged, .conflict: return
227 }
228 let mapped = view.mapped(through: edits)
229 load(document.text)
230 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
231 } 219 }
232 220
233 // MARK: - Folding 221 // MARK: - Folding
@@ -417,6 +405,7 @@ public final class OrgEditor: NSObject {
417 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) 405 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
418 } 406 }
419 onEditTiming?(clock.now - start) 407 onEditTiming?(clock.now - start)
408 onChange?()
420 } 409 }
421 410
422 /// Columns a wrapped continuation of `line` should start past the line's own start. 411 /// Columns a wrapped continuation of `line` should start past the line's own start.
@@ -475,6 +464,31 @@ public final class OrgEditor: NSObject {
475 } 464 }
476} 465}
477 466
467extension OrgEditor: DocumentBuffer {
468 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
469 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
470 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
471 let before = document.text
472 let selected = textView.selectedRange()
473 let result = try change(&document)
474 if document.text != before {
475 let edits = lineEdits(from: before, to: document.text)
476 var state = view
477 state.selection = [selected.location..<NSMaxRange(selected)]
478 let mapped = state.mapped(through: edits)
479 load(document.text)
480 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
481 let selection = mapped.selection[0]
482 setCaret(selection.lowerBound)
483 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
484 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
485 textView.undoManager?.removeAllActions(withTarget: textView)
486 }
487 onChange?()
488 return result
489 }
490}
491
478extension OrgEditor: NSTextViewDelegate { 492extension OrgEditor: NSTextViewDelegate {
479 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { 493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
480 switch selector { 494 switch selector {
Tests/OrgDocumentTests/SaveTests.swift +10
@@ -103,6 +103,16 @@ struct SaveTests {
103 #expect(doc.text == "A\nb\nc\n") 103 #expect(doc.text == "A\nb\nc\n")
104 } 104 }
105 105
106 @Test func overwriteKeepsTheReplacedVersion() throws {
107 let files = FaultyFileSystem("Z\nb\nc\n")
108 let recovery = MemoryRecovery()
109 var doc = try editedState()
110 try Saver(fileSystem: files, recovery: recovery).overwrite(&doc, to: url)
111 #expect(files.text == "A\nb\nc\n")
112 #expect(!doc.isDirty)
113 #expect(recovery.contains("Z\nb\nc\n"))
114 }
115
106 @Test func changedBetweenReadAndCheckRetries() throws { 116 @Test func changedBetweenReadAndCheckRetries() throws {
107 let files = FaultyFileSystem("a\nb\nc\n") 117 let files = FaultyFileSystem("a\nb\nc\n")
108 files.beforeRead[2] = Array("a\nb\nC\n".utf8) 118 files.beforeRead[2] = Array("a\nb\nC\n".utf8)
Tests/OrgEditorAppKitTests/EditorTests.swift +23
@@ -229,6 +229,29 @@ struct EditingTests {
229 } 229 }
230} 230}
231 231
232@MainActor
233struct BufferTests {
234 @Test func updateMapsCaretAndFoldsAndClearsUndo() {
235 let h = Harness(outline)
236 var changes = 0
237 h.editor.onChange = { changes += 1 }
238 h.caret(at: 1)
239 h.textView.insertText("x", replacementRange: NSRange(location: 4, length: 0))
240 #expect(changes == 1)
241 RunLoop.current.run(until: Date())
242 #expect(h.textView.undoManager?.canUndo == true)
243 h.editor.setFolds(FoldState(folds: [h.offset(of: "** b")]))
244 h.caret(at: h.offset(of: "* c") + 2)
245 h.editor.update { $0 = DocumentState(bytes: Array(("new\n" + outline).utf8)) }
246 #expect(changes == 2)
247 #expect(h.string == "new\n" + outline)
248 #expect(h.caret == h.offset(of: "* c") + 2)
249 #expect(h.editor.foldState.folds == [h.offset(of: "** b")])
250 #expect(h.textView.undoManager?.canUndo != true)
251 h.checkInSync()
252 }
253}
254
232@MainActor 255@MainActor
233struct RestyleTests { 256struct RestyleTests {
234 func indent(_ h: Harness, at line: String) -> CGFloat? { 257 func indent(_ h: Harness, at line: String) -> CGFloat? {
docs/plans/2026-10-05-writable-app.md +6 −6
@@ -28,7 +28,7 @@
28| File | Responsibility | 28| File | Responsibility |
29| --- | --- | 29| --- | --- |
30| `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` | 30| `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` |
31| `Sources/OrgDocument/DocumentState.swift` | `rebase(onto:)` | 31| `Sources/OrgDocument/Saving.swift` | `Saver.overwrite(_:to:)` |
32| `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` | 32| `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` |
33| `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` | 33| `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` |
34| `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` | 34| `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` |
@@ -38,7 +38,7 @@
38 38
39### Task 1: Buffers 39### Task 1: Buffers
40 40
41**Files:** `Sources/OrgDocument/Buffer.swift`, `Sources/OrgDocument/DocumentState.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift` 41**Files:** `Sources/OrgDocument/Buffer.swift`, `Sources/OrgDocument/Saving.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift`
42 42
43**Produces:** 43**Produces:**
44 44
@@ -53,9 +53,9 @@ public protocol DocumentBuffer: AnyObject {
53@MainActor 53@MainActor
54public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) } 54public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) }
55 55
56extension DocumentState { 56extension Saver {
57 /// Makes `bytes` the merge base and keeps the text, so the next save replaces them. 57 /// Writes the buffer over whatever is on disk; the replaced version goes to recovery.
58 public mutating func rebase(onto bytes: [UInt8]) 58 public func overwrite(_ state: inout DocumentState, to url: URL) throws
59} 59}
60 60
61extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?` 61extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?`
@@ -63,7 +63,7 @@ extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)
63 63
64`OrgEditor.update` records the text and selection, runs the change, and if the text changed: computes `lineEdits(from:to:)`, reloads the storage, maps folds and the selection through the edits, and removes the text view's undo actions (they refer to the old text). `save(using:to:)` and `diskChanged(to:)` become `update` calls; `diskChanged` returns the `ExternalChange`. 64`OrgEditor.update` records the text and selection, runs the change, and if the text changed: computes `lineEdits(from:to:)`, reloads the storage, maps folds and the selection through the edits, and removes the text view's undo actions (they refer to the old text). `save(using:to:)` and `diskChanged(to:)` become `update` calls; `diskChanged` returns the `ExternalChange`.
65 65
66- [ ] Tests: `rebase` keeps text and makes the buffer dirty against new bytes; editor `update` replacing text keeps the caret on the same line content, keeps folds, and leaves nothing to undo; typing calls `onChange`. 66- [ ] Tests: `overwrite` writes ours and keeps the replaced version; editor `update` replacing text keeps the caret on the same line content, keeps folds, and leaves nothing to undo; typing calls `onChange`.
67- [ ] Implement; `swift test` green. 67- [ ] Implement; `swift test` green.
68- [ ] Commit "Add document buffers". 68- [ ] Commit "Add document buffers".
69 69