krz/orgstar

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

Commit 2bdd22c60b

2bdd22c60b247fc90332f15b31c0b4fdc3c36c0e

parent: 24b5036e3f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 01:11 UTC

Resolve dangling clocks on agenda and capture clock-in

org-agenda-clock-in and a template's :clock-in both run org-clock-in, which resolves open clocks when no clock is running. The agenda (Mac and iOS) and capture now clock in through the same path as Clock In, asking in the echo area or the clock sheet; capture resolves as of when capture began.

Layout: unified · split

Sources/OrgApp/ClockModel.swift +31 −12
@@ -139,22 +139,25 @@ public final class ClockModel {
139139 }
140140 }
141141
142 /// A template's `clock-in` once its entry is filed: clocked from when capture began and,
143 /// without `clock-keep`, clocked out now, the clock it interrupted resuming with
144 /// `clock-resume`. The messages, in order.
142 /// A template's `clock-in` once its entry is filed: `org-clock-in` at the entry as when
143 /// capture began, dangling clocks resolved first, and, without `clock-keep`, clocked out
144 /// now, the clock it interrupted resuming with `clock-resume`.
145145 public func captured(
146 _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, opened: Date, now: Date = Date()
147 ) -> [String] {
148 guard options.clockIn else { return [] }
149 let previous = self.entry(session)
150 var messages = [clockIn(session, path: entry.path, at: entry.offset, now: opened)]
146 _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, paths: [String], ask: Ask,
147 opened: Date, now: Date = Date()
148 ) async -> Outcome {
149 guard options.clockIn else { return Outcome() }
150 let previous = running == nil ? nil : runningTask(session)
151 var outcome = await clockIn(session, target: .entry(path: entry.path, offset: entry.offset), paths: paths, ask: ask, now: { opened })
151152 if !options.clockKeep {
152 messages.append(clockOut(session, now: now))
153 outcome.messages.append(clockOut(session, now: now))
153154 if options.clockResume, let previous {
154 messages.append(clockIn(session, path: previous.path, at: previous.offset, now: now))
155 let resumed = await clockIn(session, target: .task(previous), paths: paths, ask: ask, now: { now })
156 outcome.messages += resumed.messages
157 if let jump = resumed.jump { outcome.jump = jump }
155158 }
156159 }
157 return messages
160 return outcome
158161 }
159162
160163 static func text(_ path: String, _ session: DocumentSession) -> String? {
@@ -265,6 +268,9 @@ public final class ClockModel {
265268 case last
266269 /// A remembered entry.
267270 case task(ClockTask)
271 /// The heading containing `offset` in `path` (the agenda's `org-agenda-clock-in`, a
272 /// capture template's `clock-in`).
273 case entry(path: String, offset: Int)
268274 }
269275
270276 /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no
@@ -276,6 +282,19 @@ public final class ClockModel {
276282 outcome.messages.append("No last clock")
277283 return outcome
278284 }
285 var target = target
286 // Resolving may edit the entry's file, so its heading is found again afterwards.
287 if case .entry(let path, let offset) = target {
288 guard let text = Self.text(path, session) else {
289 outcome.messages.append("Can't read \(path)")
290 return outcome
291 }
292 guard let task = ClockTask.at(offset, in: text, path: path) else {
293 outcome.messages.append("Before first headline")
294 return outcome
295 }
296 target = .task(task)
297 }
279298 let wasRunning = running != nil
280299 let stopped = leftover
281300 if !wasRunning {
@@ -312,7 +331,7 @@ public final class ClockModel {
312331 }
313332 location = found
314333 }
315 case .last, .task:
334 case .last, .task, .entry:
316335 let task: ClockTask
317336 if case .task(let chosen) = target { task = chosen } else { task = history[0] }
318337 guard let found = locate(task, session) else {
Sources/Orgstar/AgendaView.swift +7 −2
@@ -483,8 +483,13 @@ struct AgendaView: View {
483483 message = "No entry selected"
484484 return
485485 }
486 message = clock.clockIn(session, path: entry.path, at: offset)
487 tick += 1
486 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
487 Task {
488 let outcome = await clock.clockIn(session, target: .entry(path: entry.path, offset: offset), paths: paths, ask: commands.askClock)
489 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
490 message = outcome.messages.last { !$0.isEmpty }
491 tick += 1
492 }
488493 }
489494
490495 private func archive() {
Sources/Orgstar/CaptureView.swift +10 −2
@@ -212,8 +212,16 @@ struct CaptureView: View {
212212 case .success(let entry):
213213 commands.show("Captured to \(Self.target(template))")
214214 let options = template.options
215 for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) }
216 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) }
215 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
216 Task { [commands, clock, session] in
217 let outcome = await clock.captured(entry, options: options, session: session, paths: paths, ask: commands.askClock, opened: opened, now: now)
218 for message in outcome.messages { commands.show(message) }
219 if let jump = outcome.jump {
220 commands.jump(path: jump.path, offset: jump.offset)
221 } else if options.jumpToCaptured {
222 commands.jump(path: entry.path, offset: entry.offset)
223 }
224 }
217225 }
218226 close()
219227 }
Sources/Orgstar/ContentView.swift +1 −8
@@ -690,14 +690,7 @@ struct ContentView: View {
690690 }
691691 }
692692
693 /// Clock questions in the echo area.
694 private var askClock: ClockModel.Ask {
695 { [commands] prompt in
696 await withCheckedContinuation { continuation in
697 commands.ask(prompt) { continuation.resume(returning: $0) }
698 }
699 }
700 }
693 private var askClock: ClockModel.Ask { commands.askClock }
701694
702695 /// The files `org-resolve-clocks` looks through: the workspace's org files.
703696 private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) }
Sources/Orgstar/OrgstarApp.swift +9
@@ -62,6 +62,15 @@ final class AppCommands {
6262 self.prompt = PromptRequest(prompt: prompt, reply: reply)
6363 }
6464
65 /// Clock questions in the echo area.
66 var askClock: ClockModel.Ask {
67 { [self] prompt in
68 await withCheckedContinuation { continuation in
69 ask(prompt) { continuation.resume(returning: $0) }
70 }
71 }
72 }
73
6574 /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
6675 func answer(_ text: String?) {
6776 guard let request = prompt else { return }
Sources/OrgstarMobile/AgendaScreen.swift +12 −1
@@ -14,6 +14,7 @@ struct AgendaScreen: View {
1414 let scheduler: ReminderScheduler
1515 @State private var message: String?
1616 @Environment(ClockModel.self) private var clock
17 @Environment(MobileModel.self) private var model
1718 @State private var tick = 0
1819 @State private var views = AgendaViewLoader.builtIn
1920 @State private var viewName = "Agenda"
@@ -193,7 +194,7 @@ struct AgendaScreen: View {
193194 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
194195 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
195196 Button("Clock In", systemImage: "stopwatch") {
196 if let (path, offset) = locate(item) { message = clock.clockIn(session, path: path, at: offset) }
197 if let (path, offset) = locate(item) { clockIn(path, offset) }
197198 }
198199 Divider()
199200 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
@@ -234,6 +235,16 @@ struct AgendaScreen: View {
234235 return (path, offset)
235236 }
236237
238 /// `org-agenda-clock-in`: `org-clock-in` at the entry, dangling clocks resolved first.
239 private func clockIn(_ path: String, _ offset: Int) {
240 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
241 Task {
242 let outcome = await clock.clockIn(session, target: .entry(path: path, offset: offset), paths: paths, ask: model.clockPrompts.ask)
243 if let jump = outcome.jump { model.openRequest = .offset(jump.path, jump.offset) }
244 message = outcome.messages.last { !$0.isEmpty }
245 }
246 }
247
237248 /// Runs a command at the entry's heading, asking what it asks.
238249 private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
239250 guard let command = Commands.command(id) else { return }
Sources/OrgstarMobile/CaptureScreen.swift +4 −1
@@ -121,7 +121,10 @@ struct CaptureScreen: View {
121121 editing = true
122122 case .success(let entry):
123123 // Clocked from when capture began.
124 _ = clock.captured(entry, options: template.options, session: session, opened: context.now, now: now)
124 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
125 Task { [clock, session, opened = context.now] in
126 _ = await clock.captured(entry, options: template.options, session: session, paths: paths, ask: model.clockPrompts.ask, opened: opened, now: now)
127 }
125128 if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
126129 dismiss()
127130 }
Tests/OrgAppTests/ClockHistoryModelTests.swift +53
@@ -111,6 +111,59 @@ struct ClockHistoryModelTests {
111111 #expect(clock.leftover == nil)
112112 }
113113
114 /// `org-agenda-clock-in` runs `org-clock-in` at the entry, so it resolves first too, and
115 /// finds the entry after resolving moved it.
116 @Test func clockingInAtAnEntryResolvesDanglingClocks() async throws {
117 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n* New\n")
118 let clock = ClockModel(file: stateFile, defaults: defaults)
119 let started = clock.openClocks(session, paths: [other.path])[0].startedAt
120 let text = try #require(ClockModel.text(other.path, session))
121 let entry = (text as NSString).range(of: "* New").location
122 let answers = ScriptedAnswers(["K", "30"])
123 let outcome = await clock.clockIn(
124 session, target: .entry(path: other.path, offset: entry), paths: [other.path], ask: answers.ask, now: { started.addingTimeInterval(7200) }
125 )
126 #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
127 #expect(clock.running?.heading == "New" && outcome.messages.last == "Clock starts at [2026-09-09 Wed 11:00]")
128 let updated = try #require(ClockModel.text(other.path, session))
129 #expect(
130 updated == "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 09:30] => 0:30\n:END:\n* New\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 11:00]\n:END:\n",
131 "\(updated)"
132 )
133 }
134
135 /// A template's `clock-in` is `org-clock-in` when capture began: dangling clocks are
136 /// resolved as of then, and the entry is clocked out when it is filed.
137 @Test func captureClockInResolvesDanglingClocks() async throws {
138 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
139 let clock = ClockModel(file: stateFile, defaults: defaults)
140 let opened = clock.openClocks(session, paths: [other.path])[0].startedAt.addingTimeInterval(7200)
141 let answers = ScriptedAnswers(["K", "30"])
142 let outcome = await clock.captured(
143 (work.path, offset("* C")), options: .init(clockIn: true), session: session, paths: [other.path], ask: answers.ask,
144 opened: opened, now: opened.addingTimeInterval(600)
145 )
146 #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
147 #expect(outcome.messages.suffix(2) == ["Clock starts at [2026-09-09 Wed 11:00]", "Clock stopped after 0:10"])
148 #expect(clock.running == nil && clock.history.first?.title == "C")
149 #expect(try #require(ClockModel.text(other.path, session)).contains("=> 0:30"))
150 }
151
152 /// With a clock running the capture clock-in resolves nothing; the `clock-resume` clock-in,
153 /// with no clock running, does, as in Org.
154 @Test func captureClockInResumesTheInterruptedClock() async throws {
155 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
156 let clock = ClockModel(file: stateFile, defaults: defaults)
157 _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
158 let answers = ScriptedAnswers([])
159 let outcome = await clock.captured(
160 (work.path, offset("* C")), options: .init(clockIn: true, clockResume: true), session: session, paths: [other.path],
161 ask: answers.ask, opened: start.addingTimeInterval(600), now: start.addingTimeInterval(900)
162 )
163 #expect(answers.asked.count == 1 && answers.asked[0].hasPrefix("Dangling clock started"))
164 #expect(outcome.messages.count == 3 && clock.running?.heading == "A")
165 }
166
114167 @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws {
115168 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
116169 let clock = ClockModel(file: stateFile, defaults: defaults)