krz/orgstar

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

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 @@
1import Foundation
1import OrgCore 2import 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
119struct 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
325struct 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}