Commit 3c38fb3fdc
3c38fb3fdcea0beb6b78f8548bfa8170debe1da4
parent: 2c2f13e5b8
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-06 00:20 UTC
Babel: show errors, cancel runs
Standard error and the exit status were thrown away, so a failing
block reported success. They are kept, and the message area shows the
status and the first line of errors while the results still go in, as
org-babel-eval does. The running block's task is kept: Edit ▸ Cancel
Running Block (⌘.) stops the process and inserts nothing.
Layout: unified · split
Sources/OrgApp/BabelRunner.swift
+7 −2
| @@ -43,9 +43,12 @@ public enum BabelRunner { |
| 43 | let stdout = folder.appendingPathComponent("stdout") |
43 | let stdout = folder.appendingPathComponent("stdout") |
| 44 | FileManager.default.createFile(atPath: stdout.path, contents: nil) |
44 | FileManager.default.createFile(atPath: stdout.path, contents: nil) |
| 45 | let out = try FileHandle(forWritingTo: stdout) |
45 | let out = try FileHandle(forWritingTo: stdout) |
| |
46 | let stderr = folder.appendingPathComponent("stderr") |
| |
47 | FileManager.default.createFile(atPath: stderr.path, contents: nil) |
| |
48 | let err = try FileHandle(forWritingTo: stderr) |
| 46 | process.standardInput = stdin |
49 | process.standardInput = stdin |
| 47 | process.standardOutput = out |
50 | process.standardOutput = out |
| 48 | process.standardError = FileHandle.nullDevice |
51 | process.standardError = err |
| 49 | do { |
52 | do { |
| 50 | try process.run() |
53 | try process.run() |
| 51 | } catch { |
54 | } catch { |
| @@ -76,11 +79,13 @@ public enum BabelRunner { |
| 76 | } |
79 | } |
| 77 | } |
80 | } |
| 78 | try out.close() |
81 | try out.close() |
| |
82 | try err.close() |
| 79 | let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? "" |
83 | let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? "" |
| |
84 | let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? "" |
| 80 | let result = try? String(contentsOf: resultFile, encoding: .utf8) |
85 | let result = try? String(contentsOf: resultFile, encoding: .utf8) |
| 81 | if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty { |
86 | if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty { |
| 82 | throw Failure(description: "\(executable) isn't installed.") |
87 | throw Failure(description: "\(executable) isn't installed.") |
| 83 | } |
88 | } |
| 84 | return BabelOutput(stdout: output, resultFile: result) |
89 | return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus) |
| 85 | } |
90 | } |
| 86 | } |
91 | } |
Sources/OrgApp/DocumentSession.swift
+30 −2
| @@ -536,21 +536,49 @@ public final class DocumentSession { |
| 536 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
536 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 537 | let current = entry.buffer |
537 | let current = entry.buffer |
| 538 | let document = current.document |
538 | let document = current.document |
| |
539 | let done = output.problem ?? "Code block evaluation complete." |
| 539 | switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { |
540 | switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { |
| 540 | case .failure(.message(let message)): |
541 | case .failure(.message(let message)): |
| 541 | return message |
542 | return message |
| 542 | case .success(let edits): |
543 | case .success(let edits): |
| 543 | guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." } |
544 | guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.problem ?? output.stdout : done } |
| 544 | let result = EditResult(baseRevision: document.revision, edits: edits) |
545 | let result = EditResult(baseRevision: document.revision, edits: edits) |
| 545 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } |
546 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } |
| 546 | changed(entry) |
547 | changed(entry) |
| 547 | return "Code block evaluation complete." |
548 | return done |
| 548 | } |
549 | } |
| |
550 | } catch is CancellationError { |
| |
551 | return "Code block canceled." |
| 549 | } catch { |
552 | } catch { |
| 550 | return "\(error)" |
553 | return "\(error)" |
| 551 | } |
554 | } |
| 552 | } |
555 | } |
| 553 | |
556 | |
| |
557 | /// The block running now, to cancel. |
| |
558 | @ObservationIgnored private var babelTask: Task<String, Never>? |
| |
559 | public private(set) var isRunningBabel = false |
| |
560 | |
| |
561 | /// Runs a src block in the background; its message comes back through `report`. |
| |
562 | public func startBabel(_ job: BabelJob, report: @escaping @MainActor (String) -> Void) { |
| |
563 | babelTask?.cancel() |
| |
564 | isRunningBabel = true |
| |
565 | let task = Task { await runBabel(job) } |
| |
566 | babelTask = task |
| |
567 | Task { |
| |
568 | let message = await task.value |
| |
569 | if babelTask == task { |
| |
570 | babelTask = nil |
| |
571 | isRunningBabel = false |
| |
572 | } |
| |
573 | report(message) |
| |
574 | } |
| |
575 | } |
| |
576 | |
| |
577 | /// Stops the running block; its results aren't inserted. |
| |
578 | public func cancelBabel() { |
| |
579 | babelTask?.cancel() |
| |
580 | } |
| |
581 | |
| 554 | /// A buffer changed outside typing: the current one updates its views, another saves. |
582 | /// A buffer changed outside typing: the current one updates its views, another saves. |
| 555 | private func changed(_ entry: Entry) { |
583 | private func changed(_ entry: Entry) { |
| 556 | if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() } |
584 | if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() } |
Sources/OrgCore/Compute/Babel.swift
+18 −1
| @@ -42,9 +42,26 @@ public struct BabelJob: Sendable, Equatable { |
| 42 | public struct BabelOutput: Sendable, Equatable { |
42 | public struct BabelOutput: Sendable, Equatable { |
| 43 | public var stdout: String |
43 | public var stdout: String |
| 44 | public var resultFile: String? |
44 | public var resultFile: String? |
| 45 | public init(stdout: String, resultFile: String? = nil) { |
45 | /// What the program wrote to standard error, and its exit status: Emacs shows them in |
| |
46 | /// `*Org-Babel Error Output*` and still inserts the results. |
| |
47 | public var stderr: String |
| |
48 | public var status: Int32 |
| |
49 | public init(stdout: String, resultFile: String? = nil, stderr: String = "", status: Int32 = 0) { |
| 46 | self.stdout = stdout |
50 | self.stdout = stdout |
| 47 | self.resultFile = resultFile |
51 | self.resultFile = resultFile |
| |
52 | self.stderr = stderr |
| |
53 | self.status = status |
| |
54 | } |
| |
55 | |
| |
56 | /// A line for the message area when the program failed or complained, else nil. |
| |
57 | public var problem: String? { |
| |
58 | let first = stderr.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) |
| |
59 | switch (status, first) { |
| |
60 | case (0, nil): return nil |
| |
61 | case (0, let line?): return line |
| |
62 | case (let code, nil): return "Exited with status \(code)" |
| |
63 | case (let code, let line?): return "Exited with status \(code): \(line)" |
| |
64 | } |
| 48 | } |
65 | } |
| 49 | } |
66 | } |
| 50 | |
67 | |
Sources/OrgEditorAppKit/EditorCommands.swift
+1
| @@ -48,6 +48,7 @@ public enum EditorCommands { |
| 48 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), |
48 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), |
| 49 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), |
49 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), |
| 50 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), |
50 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), |
| |
51 | EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), |
| 51 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
52 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 52 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
53 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 53 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
54 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
Sources/Orgstar/ContentView.swift
+4 −2
| @@ -358,8 +358,8 @@ struct ContentView: View { |
| 358 | } |
358 | } |
| 359 | case .babel(let job): |
359 | case .babel(let job): |
| 360 | let run = { |
360 | let run = { |
| 361 | commands.show("Running \(job.language) block…") |
361 | commands.show("Running \(job.language) block… (⌘. to cancel)") |
| 362 | Task { commands.show(await session.runBabel(job)) } |
362 | session.startBabel(job) { commands.show($0) } |
| 363 | } |
363 | } |
| 364 | guard let path = session.path else { return } |
364 | guard let path = session.path else { return } |
| 365 | if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() } |
365 | if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() } |
| @@ -461,6 +461,8 @@ struct ContentView: View { |
| 461 | let all = session.openPaths |
461 | let all = session.openPaths |
| 462 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } |
462 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } |
| 463 | case "app.toggle-tab-bar": showTabBar.toggle() |
463 | case "app.toggle-tab-bar": showTabBar.toggle() |
| |
464 | case "app.babel.cancel": |
| |
465 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } |
| 464 | case "app.toggle-outline": toggleOutline() |
466 | case "app.toggle-outline": toggleOutline() |
| 465 | case "app.toggle-markup": showMarkup.toggle() |
467 | case "app.toggle-markup": showMarkup.toggle() |
| 466 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
468 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
Sources/Orgstar/OrgstarApp.swift
+3
| @@ -296,6 +296,9 @@ struct OrgstarApp: App { |
| 296 | FindMenu(commands: commands) |
296 | FindMenu(commands: commands) |
| 297 | } |
297 | } |
| 298 | CommandGroup(after: .textEditing) { |
298 | CommandGroup(after: .textEditing) { |
| |
299 | Button("Cancel Running Block") { commands.run("app.babel.cancel") } |
| |
300 | .keyboardShortcut(".") |
| |
301 | .disabled(!session.isRunningBabel) |
| 299 | Button("Search Notes") { commands.focusSearch = true } |
302 | Button("Search Notes") { commands.focusSearch = true } |
| 300 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
303 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 301 | } |
304 | } |
Tests/OrgAppTests/AppTests.swift
+38
| @@ -550,6 +550,44 @@ struct BabelSessionTests { |
| 550 | } |
550 | } |
| 551 | } |
551 | } |
| 552 | |
552 | |
| |
553 | @MainActor |
| |
554 | struct BabelErrorTests { |
| |
555 | func job(_ session: DocumentSession, _ path: String) throws -> BabelJob { |
| |
556 | guard case .external(.babel(let job)) = session.perform(ExecuteSrcBlock(), path: path, at: 3, expect: nil) else { |
| |
557 | throw CancellationError() |
| |
558 | } |
| |
559 | return job |
| |
560 | } |
| |
561 | |
| |
562 | /// Like `org-babel-eval`: the results go in, and the error output and status are shown. |
| |
563 | @Test func reportsErrorsAndStillInsertsResults() async throws { |
| |
564 | let notes = try Folder() |
| |
565 | let file = try notes.write("e.org", "#+begin_src sh\necho out\necho oops >&2\nexit 3\n#+end_src\n") |
| |
566 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) |
| |
567 | session.open(file.path) |
| |
568 | #expect(await session.runBabel(try job(session, file.path)) == "Exited with status 3: oops") |
| |
569 | #expect(session.document?.text.hasSuffix("#+RESULTS:\n: out\n") == true) |
| |
570 | } |
| |
571 | |
| |
572 | @Test func cancelsARunningBlock() async throws { |
| |
573 | let notes = try Folder() |
| |
574 | let file = try notes.write("s.org", "#+begin_src sh\nsleep 20\necho late\n#+end_src\n") |
| |
575 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) |
| |
576 | session.open(file.path) |
| |
577 | let start = ContinuousClock.now |
| |
578 | var message: String? |
| |
579 | session.startBabel(try job(session, file.path)) { message = $0 } |
| |
580 | #expect(session.isRunningBabel) |
| |
581 | try await Task.sleep(for: .milliseconds(200)) |
| |
582 | session.cancelBabel() |
| |
583 | while message == nil, ContinuousClock.now - start < .seconds(5) { try await Task.sleep(for: .milliseconds(20)) } |
| |
584 | #expect(message == "Code block canceled.") |
| |
585 | #expect(!session.isRunningBabel) |
| |
586 | #expect(ContinuousClock.now - start < .seconds(5)) |
| |
587 | #expect(session.document?.text.contains("#+RESULTS:") == false) |
| |
588 | } |
| |
589 | } |
| |
590 | |
| 553 | struct EmacsExportTests { |
591 | struct EmacsExportTests { |
| 554 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
592 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 555 | func exportsBesideTheFile() async throws { |
593 | func exportsBesideTheFile() async throws { |