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 | import OrgCore |
2 | import OrgCore |
| 2 | |
3 | |
| 3 | /// One open file: its text, tree, revision, undo history, and the bytes last read from or |
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 | return inverse |
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 | // MARK: - Disk |
117 | // MARK: - Disk |
| 99 | |
118 | |
| 100 | /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited |
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 | return outcome |
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 | // MARK: - Disk |
218 | // MARK: - Disk |
| 172 | |
219 | |
| 173 | /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept |
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 | #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9]) |
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 | #expect(OrgEditor.hangingColumns(line) == expected) |
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 | } |