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, | 142 | /// A template's `clock-in` once its entry is filed: `org-clock-in` at the entry as when |
| 143 | /// without `clock-keep`, clocked out now, the clock it interrupted resuming with | 143 | /// capture began, dangling clocks resolved first, and, without `clock-keep`, clocked out |
| 144 | /// `clock-resume`. The messages, in order. | 144 | /// now, the clock it interrupted resuming with `clock-resume`. |
| 145 | public func captured( | 145 | public func captured( |
| 146 | _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, opened: Date, now: Date = Date() | 146 | _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, paths: [String], ask: Ask, |
| 147 | ) -> [String] { | 147 | opened: Date, now: Date = Date() |
| 148 | guard options.clockIn else { return [] } | 148 | ) async -> Outcome { |
| 149 | let previous = self.entry(session) | 149 | guard options.clockIn else { return Outcome() } |
| 150 | var messages = [clockIn(session, path: entry.path, at: entry.offset, now: opened)] | 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 | if !options.clockKeep { | 152 | if !options.clockKeep { |
| 152 | messages.append(clockOut(session, now: now)) | 153 | outcome.messages.append(clockOut(session, now: now)) |
| 153 | if options.clockResume, let previous { | 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 | static func text(_ path: String, _ session: DocumentSession) -> String? { | 163 | static func text(_ path: String, _ session: DocumentSession) -> String? { |
| @@ -265,6 +268,9 @@ public final class ClockModel { | |||
| 265 | case last | 268 | case last |
| 266 | /// A remembered entry. | 269 | /// A remembered entry. |
| 267 | case task(ClockTask) | 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 | /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no | 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 | outcome.messages.append("No last clock") | 282 | outcome.messages.append("No last clock") |
| 277 | return outcome | 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 | let wasRunning = running != nil | 298 | let wasRunning = running != nil |
| 280 | let stopped = leftover | 299 | let stopped = leftover |
| 281 | if !wasRunning { | 300 | if !wasRunning { |
| @@ -312,7 +331,7 @@ public final class ClockModel { | |||
| 312 | } | 331 | } |
| 313 | location = found | 332 | location = found |
| 314 | } | 333 | } |
| 315 | case .last, .task: | 334 | case .last, .task, .entry: |
| 316 | let task: ClockTask | 335 | let task: ClockTask |
| 317 | if case .task(let chosen) = target { task = chosen } else { task = history[0] } | 336 | if case .task(let chosen) = target { task = chosen } else { task = history[0] } |
| 318 | guard let found = locate(task, session) else { | 337 | guard let found = locate(task, session) else { |
Sources/Orgstar/AgendaView.swift +7 −2
| @@ -483,8 +483,13 @@ struct AgendaView: View { | |||
| 483 | message = "No entry selected" | 483 | message = "No entry selected" |
| 484 | return | 484 | return |
| 485 | } | 485 | } |
| 486 | message = clock.clockIn(session, path: entry.path, at: offset) | 486 | let paths = workspace.files.filter { $0.kind == .org }.map(\.path) |
| 487 | tick += 1 | 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 | private func archive() { | 495 | private func archive() { |
Sources/Orgstar/CaptureView.swift +10 −2
| @@ -212,8 +212,16 @@ struct CaptureView: View { | |||
| 212 | case .success(let entry): | 212 | case .success(let entry): |
| 213 | commands.show("Captured to \(Self.target(template))") | 213 | commands.show("Captured to \(Self.target(template))") |
| 214 | let options = template.options | 214 | let options = template.options |
| 215 | for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) } | 215 | let paths = workspace.files.filter { $0.kind == .org }.map(\.path) |
| 216 | if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } | 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 | close() | 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. | 693 | private var askClock: ClockModel.Ask { commands.askClock } |
| 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 | } | ||
| 701 | 694 | ||
| 702 | /// The files `org-resolve-clocks` looks through: the workspace's org files. | 695 | /// The files `org-resolve-clocks` looks through: the workspace's org files. |
| 703 | private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) } | 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 | self.prompt = PromptRequest(prompt: prompt, reply: reply) | 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 | /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back. | 74 | /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back. |
| 66 | func answer(_ text: String?) { | 75 | func answer(_ text: String?) { |
| 67 | guard let request = prompt else { return } | 76 | guard let request = prompt else { return } |
Sources/OrgstarMobile/AgendaScreen.swift +12 −1
| @@ -14,6 +14,7 @@ struct AgendaScreen: View { | |||
| 14 | let scheduler: ReminderScheduler | 14 | let scheduler: ReminderScheduler |
| 15 | @State private var message: String? | 15 | @State private var message: String? |
| 16 | @Environment(ClockModel.self) private var clock | 16 | @Environment(ClockModel.self) private var clock |
| 17 | @Environment(MobileModel.self) private var model | ||
| 17 | @State private var tick = 0 | 18 | @State private var tick = 0 |
| 18 | @State private var views = AgendaViewLoader.builtIn | 19 | @State private var views = AgendaViewLoader.builtIn |
| 19 | @State private var viewName = "Agenda" | 20 | @State private var viewName = "Agenda" |
| @@ -193,7 +194,7 @@ struct AgendaScreen: View { | |||
| 193 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } | 194 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } |
| 194 | Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } | 195 | Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } |
| 195 | Button("Clock In", systemImage: "stopwatch") { | 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 | Divider() | 199 | Divider() |
| 199 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } | 200 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } |
| @@ -234,6 +235,16 @@ struct AgendaScreen: View { | |||
| 234 | return (path, offset) | 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 | /// Runs a command at the entry's heading, asking what it asks. | 248 | /// Runs a command at the entry's heading, asking what it asks. |
| 238 | private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) { | 249 | private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) { |
| 239 | guard let command = Commands.command(id) else { return } | 250 | guard let command = Commands.command(id) else { return } |
Sources/OrgstarMobile/CaptureScreen.swift +4 −1
| @@ -121,7 +121,10 @@ struct CaptureScreen: View { | |||
| 121 | editing = true | 121 | editing = true |
| 122 | case .success(let entry): | 122 | case .success(let entry): |
| 123 | // Clocked from when capture began. | 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 | if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) } | 128 | if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) } |
| 126 | dismiss() | 129 | dismiss() |
| 127 | } | 130 | } |
Tests/OrgAppTests/ClockHistoryModelTests.swift +53
| @@ -111,6 +111,59 @@ struct ClockHistoryModelTests { | |||
| 111 | #expect(clock.leftover == nil) | 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 | @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws { | 167 | @Test func jumpingFromADanglingClockShowsItAfterClockingIn() async throws { |
| 115 | let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n") | 168 | let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n") |
| 116 | let clock = ClockModel(file: stateFile, defaults: defaults) | 169 | let clock = ClockModel(file: stateFile, defaults: defaults) |