Commit 2cb9910daf
Verified · cmc
Layout: unified · split
Sources/OrgDocument/Buffer.swift added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import 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 | |
| 6 | public 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 | |
| 14 | public 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 | 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 | 154 | // MARK: - Saving |
| 155 | 155 | |
| 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. | |
| 158 | 157 | @discardableResult |
| 159 | 158 | 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) } | |
| 169 | 160 | } |
| 170 | 161 | |
| 162 | /// Called after every change to the document, typed or not. | |
| 163 | public var onChange: (() -> Void)? | |
| 164 | ||
| 171 | 165 | // MARK: - Commands |
| 172 | 166 | |
| 173 | 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 | 214 | /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept |
| 221 | 215 | /// 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) } | |
| 231 | 219 | } |
| 232 | 220 | |
| 233 | 221 | // MARK: - Folding |
| @@ -417,6 +405,7 @@ public final class OrgEditor: NSObject { | ||
| 417 | 405 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 418 | 406 | } |
| 419 | 407 | onEditTiming?(clock.now - start) |
| 408 | onChange?() | |
| 420 | 409 | } |
| 421 | 410 | |
| 422 | 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 | |
| 467 | extension 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 | ||
| 478 | 492 | extension OrgEditor: NSTextViewDelegate { |
| 479 | 493 | public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { |
| 480 | 494 | switch selector { |
Tests/OrgDocumentTests/SaveTests.swift +10
| @@ -103,6 +103,16 @@ struct SaveTests { | ||
| 103 | 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 | 116 | @Test func changedBetweenReadAndCheckRetries() throws { |
| 107 | 117 | let files = FaultyFileSystem("a\nb\nc\n") |
| 108 | 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 | |
| 233 | struct 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 | 255 | @MainActor |
| 233 | 256 | struct RestyleTests { |
| 234 | 257 | func indent(_ h: Harness, at line: String) -> CGFloat? { |
docs/plans/2026-10-05-writable-app.md +6 −6
| @@ -28,7 +28,7 @@ | ||
| 28 | 28 | | File | Responsibility | |
| 29 | 29 | | --- | --- | |
| 30 | 30 | | `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` | |
| 31 | | `Sources/OrgDocument/DocumentState.swift` | `rebase(onto:)` | | |
| 31 | | `Sources/OrgDocument/Saving.swift` | `Saver.overwrite(_:to:)` | | |
| 32 | 32 | | `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` | |
| 33 | 33 | | `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` | |
| 34 | 34 | | `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` | |
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 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 | 43 | **Produces:** |
| 44 | 44 | |
| @@ -53,9 +53,9 @@ public protocol DocumentBuffer: AnyObject { | ||
| 53 | 53 | @MainActor |
| 54 | 54 | public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) } |
| 55 | 55 | |
| 56 | extension 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]) | |
| 56 | extension 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 | |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | 61 | extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?` |
| @@ -63,7 +63,7 @@ extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void) | ||
| 63 | 63 | |
| 64 | 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 | 67 | - [ ] Implement; `swift test` green. |
| 68 | 68 | - [ ] Commit "Add document buffers". |
| 69 | 69 | |