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 {
7878 }
7979 throw SaveError.fileKeepsChanging
8080 }
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 }
8191}
Sources/OrgEditorAppKit/OrgEditor.swift +34 −20
@@ -153,21 +153,15 @@ public final class OrgEditor: NSObject {
153153
154154 // MARK: - Saving
155155
156 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and
157 /// folds are mapped through the change.
156 /// Saves through `saver`. A merge that changes the buffer reloads the text view.
158157 @discardableResult
159158 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
160 let before = document.text
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
159 try update { try saver.save(&$0, to: url) }
169160 }
170161
162 /// Called after every change to the document, typed or not.
163 public var onChange: (() -> Void)?
164
171165 // MARK: - Commands
172166
173167 /// Called with messages commands report, such as why one couldn't run here.
@@ -219,15 +213,9 @@ public final class OrgEditor: NSObject {
219213
220214 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
221215 /// its headings.
222 public func diskChanged(to bytes: [UInt8]) {
223 let edits: [TextEdit]
224 switch document.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))
216 @discardableResult
217 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
218 update { $0.diskChanged(to: bytes) }
231219 }
232220
233221 // MARK: - Folding
@@ -417,6 +405,7 @@ public final class OrgEditor: NSObject {
417405 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
418406 }
419407 onEditTiming?(clock.now - start)
408 onChange?()
420409 }
421410
422411 /// Columns a wrapped continuation of `line` should start past the line's own start.
@@ -475,6 +464,31 @@ public final class OrgEditor: NSObject {
475464 }
476465}
477466
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
478492extension OrgEditor: NSTextViewDelegate {
479493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
480494 switch selector {
Tests/OrgDocumentTests/SaveTests.swift +10
@@ -103,6 +103,16 @@ struct SaveTests {
103103 #expect(doc.text == "A\nb\nc\n")
104104 }
105105
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
106116 @Test func changedBetweenReadAndCheckRetries() throws {
107117 let files = FaultyFileSystem("a\nb\nc\n")
108118 files.beforeRead[2] = Array("a\nb\nC\n".utf8)
Tests/OrgEditorAppKitTests/EditorTests.swift +23
@@ -229,6 +229,29 @@ struct EditingTests {
229229 }
230230}
231231
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
232255@MainActor
233256struct RestyleTests {
234257 func indent(_ h: Harness, at line: String) -> CGFloat? {
docs/plans/2026-10-05-writable-app.md +6 −6
@@ -28,7 +28,7 @@
2828| File | Responsibility |
2929| --- | --- |
3030| `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` |
31| `Sources/OrgDocument/DocumentState.swift` | `rebase(onto:)` |
31| `Sources/OrgDocument/Saving.swift` | `Saver.overwrite(_:to:)` |
3232| `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` |
3333| `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` |
3434| `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` |
@@ -38,7 +38,7 @@
3838
3939### Task 1: Buffers
4040
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`
4242
4343**Produces:**
4444
@@ -53,9 +53,9 @@ public protocol DocumentBuffer: AnyObject {
5353@MainActor
5454public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) }
5555
56extension DocumentState {
57 /// Makes `bytes` the merge base and keeps the text, so the next save replaces them.
58 public mutating func rebase(onto bytes: [UInt8])
56extension Saver {
57 /// Writes the buffer over whatever is on disk; the replaced version goes to recovery.
58 public func overwrite(_ state: inout DocumentState, to url: URL) throws
5959}
6060
6161extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?`
@@ -63,7 +63,7 @@ extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)
6363
6464`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`.
6565
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`.
6767- [ ] Implement; `swift test` green.
6868- [ ] Commit "Add document buffers".
6969