Commit 1f46158efa
1f46158efa8b451c61b9bd5cefc9f18bcd6f27df
parent: d926f7c432
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-05 03:30 UTC
Run commands from documents and the editor
Layout: unified · split
Sources/OrgDocument/DocumentState.swift
+19
| @@ -1,3 +1,4 @@ |
| 1 | import Foundation |
| 1 | 2 | import OrgCore |
| 2 | 3 | |
| 3 | 4 | /// One open file: its text, tree, revision, undo history, and the bytes last read from or |
| @@ -95,6 +96,24 @@ public struct DocumentState: Sendable { |
| 95 | 96 | return inverse |
| 96 | 97 | } |
| 97 | 98 | |
| 99 | // MARK: - Commands |
| 100 | |
| 101 | /// Runs `command` at `selection` and applies its edits as one undo step. |
| 102 | public mutating func run( |
| 103 | _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(), |
| 104 | calendar: Calendar = .current, answers: [String: String] = [:] |
| 105 | ) throws -> CommandStep { |
| 106 | let context = EditContext( |
| 107 | revision: revision, text: text, tree: tree, selection: selection, |
| 108 | now: now, calendar: calendar, answers: answers |
| 109 | ) |
| 110 | let step = command.run(in: context) |
| 111 | if case .commit(let result) = step, !result.edits.isEmpty { |
| 112 | try apply(result.edits, baseRevision: result.baseRevision) |
| 113 | } |
| 114 | return step |
| 115 | } |
| 116 | |
| 98 | 117 | // MARK: - Disk |
| 99 | 118 | |
| 100 | 119 | /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited |
Sources/OrgEditorAppKit/OrgEditor.swift
+47
| @@ -168,6 +168,53 @@ public final class OrgEditor: NSObject { |
| 168 | 168 | return outcome |
| 169 | 169 | } |
| 170 | 170 | |
| 171 | // MARK: - Commands |
| 172 | |
| 173 | /// Called with messages commands report, such as why one couldn't run here. |
| 174 | public var onMessage: ((String) -> Void)? |
| 175 | |
| 176 | /// Runs `command` at the selection. Edits go through the text view, as typing does, so |
| 177 | /// they form one undo step and reach the document through the same path. |
| 178 | @discardableResult |
| 179 | public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep { |
| 180 | let selected = textView.selectedRange() |
| 181 | let context = EditContext( |
| 182 | revision: document.revision, text: document.text, tree: document.tree, |
| 183 | selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers |
| 184 | ) |
| 185 | let step = command.run(in: context) |
| 186 | switch step { |
| 187 | case .commit(let result): |
| 188 | guard textView.isEditable || result.edits.isEmpty else { |
| 189 | onMessage?("This file is read-only.") |
| 190 | return .failed("read-only") |
| 191 | } |
| 192 | if !result.edits.isEmpty, let storage = textView.textStorage { |
| 193 | // Each command is its own undo step, not merged with typing around it. |
| 194 | textView.breakUndoCoalescing() |
| 195 | textView.undoManager?.beginUndoGrouping() |
| 196 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { |
| 197 | let range = NSRange(edit.range) |
| 198 | guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue } |
| 199 | storage.replaceCharacters(in: range, with: edit.replacement) |
| 200 | textView.didChangeText() |
| 201 | } |
| 202 | textView.undoManager?.endUndoGrouping() |
| 203 | textView.breakUndoCoalescing() |
| 204 | } |
| 205 | if let selection = result.selection?.first { |
| 206 | setCaret(selection.lowerBound) |
| 207 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| 208 | } |
| 209 | for case .message(let text) in result.effects { onMessage?(text) } |
| 210 | case .failed(let message): |
| 211 | onMessage?(message) |
| 212 | case .prompt: |
| 213 | break |
| 214 | } |
| 215 | return step |
| 216 | } |
| 217 | |
| 171 | 218 | // MARK: - Disk |
| 172 | 219 | |
| 173 | 220 | /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept |
Tests/OrgDocumentTests/DocumentStateTests.swift
+22
| @@ -115,3 +115,25 @@ struct ViewStateTests { |
| 115 | 115 | #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9]) |
| 116 | 116 | } |
| 117 | 117 | } |
| 118 | |
| 119 | struct CommandRunTests { |
| 120 | @Test func runAppliesEditsAsOneStep() throws { |
| 121 | var doc = state("* a\n") |
| 122 | let step = try doc.run(TodoCycle(), selection: [2..<2]) |
| 123 | guard case .commit(let result) = step else { |
| 124 | Issue.record("expected a commit") |
| 125 | return |
| 126 | } |
| 127 | #expect(doc.text == "* TODO a\n") |
| 128 | #expect(result.selection == [7..<7]) |
| 129 | #expect(doc.tree.green == OrgParser.parse(doc.text).green) |
| 130 | _ = doc.undo() |
| 131 | #expect(doc.text == "* a\n") |
| 132 | } |
| 133 | |
| 134 | @Test func failuresChangeNothing() throws { |
| 135 | var doc = state("text\n") |
| 136 | #expect(try doc.run(TodoCycle(), selection: [0..<0]) == .failed("Before first headline")) |
| 137 | #expect(doc.revision == 0) |
| 138 | } |
| 139 | } |
Tests/OrgEditorAppKitTests/EditorTests.swift
+33
| @@ -320,3 +320,36 @@ struct HangingIndentTests { |
| 320 | 320 | #expect(OrgEditor.hangingColumns(line) == expected) |
| 321 | 321 | } |
| 322 | 322 | } |
| 323 | |
| 324 | @MainActor |
| 325 | struct CommandTests { |
| 326 | @Test func performEditsThroughTheTextView() { |
| 327 | let h = Harness("* a :t:\nbody\n") |
| 328 | var messages: [String] = [] |
| 329 | h.editor.onMessage = { messages.append($0) } |
| 330 | h.caret(at: 2) |
| 331 | h.editor.perform(PriorityUp()) |
| 332 | #expect(h.string.hasPrefix("* [#B] a ")) |
| 333 | #expect(h.caret == 2) |
| 334 | h.checkInSync() |
| 335 | // Undo groups by event: let the run loop close the first command's group, as a key |
| 336 | // press would. |
| 337 | RunLoop.current.run(until: Date()) |
| 338 | h.editor.perform(TodoCycle()) |
| 339 | #expect(h.string.hasPrefix("* TODO [#B] a ")) |
| 340 | RunLoop.current.run(until: Date()) |
| 341 | h.textView.undoManager?.undo() |
| 342 | #expect(h.string.hasPrefix("* [#B] a ")) |
| 343 | h.checkInSync() |
| 344 | h.caret(at: h.offset(of: "body")) |
| 345 | h.editor.perform(PromoteHeading()) |
| 346 | #expect(messages == ["Not on a heading"]) |
| 347 | } |
| 348 | |
| 349 | @Test func readOnlyEditorsRefuseEdits() { |
| 350 | let editor = OrgEditor(document: DocumentState(bytes: Array("* a\n".utf8)), editable: false) |
| 351 | editor.textView.setSelectedRange(NSRange(location: 2, length: 0)) |
| 352 | #expect(editor.perform(TodoCycle()) == .failed("read-only")) |
| 353 | #expect(editor.document.text == "* a\n") |
| 354 | } |
| 355 | } |