krz/orgstar

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

Commit 6f59c47b8d

6f59c47b8d9df3573ea44dc9f4e9471d3acc833a

parent: bb0f452e7f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 05:40 UTC

Cancel Emacs exports and table recalculations with ⌘.

Cancel Running Task (⌘.) stops a running block, or an export or table recalculation running in Emacs.

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +2
@@ -721,6 +721,8 @@ public final class DocumentSession {
721 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } 721 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
722 changed(entry) 722 changed(entry)
723 return "Recalculated in Emacs" 723 return "Recalculated in Emacs"
724 } catch is CancellationError {
725 return "Recalculation canceled"
724 } catch { 726 } catch {
725 return "\(error)" 727 return "\(error)"
726 } 728 }
Sources/OrgEditorAppKit/EditorCommands.swift +1 −1
@@ -68,7 +68,7 @@ public enum EditorCommands {
68 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), 68 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app),
69 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), 69 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app),
70 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), 70 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app),
71 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), 71 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Task", action: .app),
72 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), 72 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
73 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 73 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
74 EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), 74 EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app),
Sources/Orgstar/ContentView.swift +11 −3
@@ -401,7 +401,7 @@ struct ContentView: View {
401 let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"] 401 let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"]
402 guard let function = functions[format] else { return } 402 guard let function = functions[format] else { return }
403 commands.show("Exporting with Emacs…") 403 commands.show("Exporting with Emacs…")
404 Task { 404 commands.runInEmacs {
405 do { 405 do {
406 var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function)) 406 var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function))
407 if let destination, destination.standardizedFileURL != written.standardizedFileURL { 407 if let destination, destination.standardizedFileURL != written.standardizedFileURL {
@@ -411,6 +411,8 @@ struct ContentView: View {
411 } 411 }
412 commands.show("Exported to \(written.lastPathComponent)") 412 commands.show("Exported to \(written.lastPathComponent)")
413 if open { NSWorkspace.shared.open(written) } 413 if open { NSWorkspace.shared.open(written) }
414 } catch is CancellationError {
415 commands.show("Export canceled")
414 } catch { 416 } catch {
415 commands.show("\(error)") 417 commands.show("\(error)")
416 } 418 }
@@ -435,7 +437,7 @@ struct ContentView: View {
435 case .emacsTable(let table, let point, let form, let reason, let lisp): 437 case .emacsTable(let table, let point, let form, let reason, let lisp):
436 let run = { 438 let run = {
437 commands.show("Recalculating in Emacs (\(reason))…") 439 commands.show("Recalculating in Emacs (\(reason))…")
438 Task { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) } 440 commands.runInEmacs { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) }
439 } 441 }
440 guard lisp, let path = session.path, let text = session.document?.text else { return run() } 442 guard lisp, let path = session.path, let text = session.document?.text else { return run() }
441 let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound)) 443 let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound))
@@ -550,7 +552,13 @@ struct ContentView: View {
550 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } 552 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) }
551 case "app.toggle-tab-bar": showTabBar.toggle() 553 case "app.toggle-tab-bar": showTabBar.toggle()
552 case "app.babel.cancel": 554 case "app.babel.cancel":
553 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } 555 if session.isRunningBabel {
556 session.cancelBabel()
557 } else if let task = commands.emacsTask {
558 task.cancel()
559 } else {
560 commands.show("Nothing is running")
561 }
554 case "app.toggle-outline": toggleOutline() 562 case "app.toggle-outline": toggleOutline()
555 case "app.toggle-backlinks": backlinksShown.toggle() 563 case "app.toggle-backlinks": backlinksShown.toggle()
556 case "app.toggle-inspector": inspectorShown.toggle() 564 case "app.toggle-inspector": inspectorShown.toggle()
Sources/Orgstar/OrgstarApp.swift +17 −2
@@ -70,6 +70,21 @@ final class AppCommands {
70 request.reply(text) 70 request.reply(text)
71 } 71 }
72 72
73 /// An export or table recalculation running in Emacs, for Cancel Running Task.
74 private(set) var emacsTask: Task<Void, Never>?
75 private var emacsSerial = 0
76
77 /// Runs `work` as the Emacs task, cancelling the one before.
78 func runInEmacs(_ work: @escaping @MainActor () async -> Void) {
79 emacsTask?.cancel()
80 emacsSerial += 1
81 let serial = emacsSerial
82 emacsTask = Task {
83 await work()
84 if emacsSerial == serial { emacsTask = nil }
85 }
86 }
87
73 /// An app command from a menu, run by the main window. 88 /// An app command from a menu, run by the main window.
74 private(set) var runRequest: (id: String, serial: Int)? 89 private(set) var runRequest: (id: String, serial: Int)?
75 private var runSerial = 0 90 private var runSerial = 0
@@ -363,9 +378,9 @@ struct OrgstarApp: App {
363 FindMenu(commands: commands) 378 FindMenu(commands: commands)
364 } 379 }
365 CommandGroup(after: .textEditing) { 380 CommandGroup(after: .textEditing) {
366 Button("Cancel Running Block") { commands.run("app.babel.cancel") } 381 Button("Cancel Running Task") { commands.run("app.babel.cancel") }
367 .keyboardShortcut(".") 382 .keyboardShortcut(".")
368 .disabled(!session.isRunningBabel) 383 .disabled(!session.isRunningBabel && commands.emacsTask == nil)
369 Button("Search Notes") { commands.focusSearch = true } 384 Button("Search Notes") { commands.focusSearch = true }
370 .keyboardShortcut("f", modifiers: [.command, .shift]) 385 .keyboardShortcut("f", modifiers: [.command, .shift])
371 } 386 }
Tests/OrgAppTests/AppTests.swift +18
@@ -721,6 +721,24 @@ struct EmacsFallbackTests {
721 #expect(message == "Recalculated in Emacs") 721 #expect(message == "Recalculated in Emacs")
722 #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n") 722 #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n")
723 } 723 }
724
725 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
726 func cancelingARecalculationLeavesTheTable() async throws {
727 let notes = try Folder()
728 let text = "| n | twice |\n|---+-------|\n| a | |\n#+TBLFM: $2=$1*2\n"
729 let file = try notes.write("t.org", text)
730 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
731 session.open(file.path)
732 let step = session.perform(TableRecalculate(all: true), path: file.path, at: 2, expect: nil)
733 guard case .external(.emacsTable(let table, let point, let form, _, _)) = step else {
734 Issue.record("\(step)")
735 return
736 }
737 let task = Task { await session.recalculateInEmacs(table: table, point: point, form: form) }
738 task.cancel()
739 #expect(await task.value == "Recalculation canceled")
740 #expect(session.document?.text == text)
741 }
724} 742}
725 743
726@MainActor 744@MainActor