Commit 2bdd22c60b
Verified · cmc
Layout: unified · split
Sources/OrgApp/ClockModel.swift +31 −12
| @@ -139,22 +139,25 @@ public final class ClockModel { | ||
| 139 | 139 | } |
| 140 | 140 | } |
| 141 | 141 | |
| 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`. | |
| 145 | 145 | 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 }) | |
| 151 | 152 | if !options.clockKeep { |
| 152 | messages.append(clockOut(session, now: now)) | |
| 153 | outcome.messages.append(clockOut(session, now: now)) | |
| 153 | 154 | 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 } | |
| 155 | 158 | } |
| 156 | 159 | } |
| 157 | return messages | |
| 160 | return outcome | |
| 158 | 161 | } |
| 159 | 162 | |
| 160 | 163 | static func text(_ path: String, _ session: DocumentSession) -> String? { |
| @@ -265,6 +268,9 @@ public final class ClockModel { | ||
| 265 | 268 | case last |
| 266 | 269 | /// A remembered entry. |
| 267 | 270 | 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) | |
| 268 | 274 | } |
| 269 | 275 | |
| 270 | 276 | /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no |
| @@ -276,6 +282,19 @@ public final class ClockModel { | ||
| 276 | 282 | outcome.messages.append("No last clock") |
| 277 | 283 | return outcome |
| 278 | 284 | } |
| 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 | } | |
| 279 | 298 | let wasRunning = running != nil |
| 280 | 299 | let stopped = leftover |
| 281 | 300 | if !wasRunning { |
| @@ -312,7 +331,7 @@ public final class ClockModel { | ||
| 312 | 331 | } |
| 313 | 332 | location = found |
| 314 | 333 | } |
| 315 | case .last, .task: | |
| 334 | case .last, .task, .entry: | |
| 316 | 335 | let task: ClockTask |
| 317 | 336 | if case .task(let chosen) = target { task = chosen } else { task = history[0] } |
| 318 | 337 | guard let found = locate(task, session) else { |
Sources/Orgstar/AgendaView.swift +7 −2
| @@ -483,8 +483,13 @@ struct AgendaView: View { | ||
| 483 | 483 | message = "No entry selected" |
| 484 | 484 | return |
| 485 | 485 | } |
| 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 | } | |
| 488 | 493 | } |
| 489 | 494 | |
| 490 | 495 | private func archive() { |
Sources/Orgstar/CaptureView.swift +10 −2
| @@ -212,8 +212,16 @@ struct CaptureView: View { | ||
| 212 | 212 | case .success(let entry): |
| 213 | 213 | commands.show("Captured to \(Self.target(template))") |
| 214 | 214 | 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 | } | |
| 217 | 225 | } |
| 218 | 226 | close() |
| 219 | 227 | } |
Sources/Orgstar/ContentView.swift +1 −8
| @@ -690,14 +690,7 @@ struct ContentView: View { | ||
| 690 | 690 | } |
| 691 | 691 | } |
| 692 | 692 | |
| 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 } | |
| 701 | 694 | |
| 702 | 695 | /// The files `org-resolve-clocks` looks through: the workspace's org files. |
| 703 | 696 | private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) } |
Sources/Orgstar/OrgstarApp.swift +9
| @@ -62,6 +62,15 @@ final class AppCommands { | ||
| 62 | 62 | self.prompt = PromptRequest(prompt: prompt, reply: reply) |
| 63 | 63 | } |
| 64 | 64 | |
| 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 | ||
| 65 | 74 | /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back. |
| 66 | 75 | func answer(_ text: String?) { |
| 67 | 76 | guard let request = prompt else { return } |
Sources/OrgstarMobile/AgendaScreen.swift +12 −1
| @@ -14,6 +14,7 @@ struct AgendaScreen: View { | ||
| 14 | 14 | let scheduler: ReminderScheduler |
| 15 | 15 | @State private var message: String? |
| 16 | 16 | @Environment(ClockModel.self) private var clock |
| 17 | @Environment(MobileModel.self) private var model | |
| 17 | 18 | @State private var tick = 0 |
| 18 | 19 | @State private var views = AgendaViewLoader.builtIn |
| 19 | 20 | @State private var viewName = "Agenda" |
| @@ -193,7 +194,7 @@ struct AgendaScreen: View { | ||
| 193 | 194 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } |
| 194 | 195 | Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } |
| 195 | 196 | 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) } | |
| 197 | 198 | } |
| 198 | 199 | Divider() |
| 199 | 200 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } |
| @@ -234,6 +235,16 @@ struct AgendaScreen: View { | ||
| 234 | 235 | return (path, offset) |
| 235 | 236 | } |
| 236 | 237 | |
| 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 | ||
| 237 | 248 | /// Runs a command at the entry's heading, asking what it asks. |
| 238 | 249 | private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) { |
| 239 | 250 | guard let command = Commands.command(id) else { return } |
Sources/OrgstarMobile/CaptureScreen.swift +4 −1
| @@ -121,7 +121,10 @@ struct CaptureScreen: View { | ||
| 121 | 121 | editing = true |
| 122 | 122 | case .success(let entry): |
| 123 | 123 | // 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 | } | |
| 125 | 128 | if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) } |
| 126 | 129 | dismiss() |
| 127 | 130 | } |
Tests/OrgAppTests/ClockHistoryModelTests.swift +53
| @@ -111,6 +111,59 @@ struct ClockHistoryModelTests { | ||
| 111 | 111 | #expect(clock.leftover == nil) |
| 112 | 112 | } |
| 113 | 113 | |
| 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 | ||
| 114 | 167 | @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws { |
| 115 | 168 | let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n") |
| 116 | 169 | let clock = ClockModel(file: stateFile, defaults: defaults) |