krz/orgstar

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

Commit 789201d6da

789201d6da70d9dcfef19dbb7558895fed7c2118

parent: d93697b86a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:45 UTC

Mac commands, menus and settings for clock history and idle time

Clock In to Recent Entry, Clock In to Last Entry, Go to Recent Clocked
Entry, Mark as Default Clock Task and Resolve Open Clocks, in Org > Clock,
the toolbar clock menu and the menu bar item with the recent entries.
Idle time is checked every minute while a clock runs. Keys: C-c C-x C-x,
C-c C-x C-z (Emacs), C-M-s-i, C-M-s-j (Mac), SPC m c G/I/d/r (Doom).

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +2 −1
@@ -638,7 +638,8 @@ public struct EmacsImport: Sendable {
638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column", 638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline", 639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
640 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out", 640 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out",
641 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", 641 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", "org-clock-in-last": "app.clock.in-last",
642 "org-resolve-clocks": "app.clock.resolve", "org-clock-mark-default-task": "app.clock.default",
642 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive", 643 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
643 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda", 644 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
644 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open", 645 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open",
Sources/OrgCore/Keymap/Presets.swift +28
@@ -370,6 +370,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
370 command = "app.clock.goto" 370 command = "app.clock.goto"
371 mode = "normal" 371 mode = "normal"
372 [[bind]] 372 [[bind]]
373 keys = "SPC m c G"
374 command = "app.clock.goto-recent"
375 mode = "normal"
376 [[bind]]
377 keys = "SPC m c I"
378 command = "app.clock.in-last"
379 mode = "normal"
380 [[bind]]
381 keys = "SPC m c d"
382 command = "app.clock.default"
383 mode = "normal"
384 [[bind]]
385 keys = "SPC m c r"
386 command = "app.clock.resolve"
387 mode = "normal"
388 [[bind]]
373 keys = "SPC m c R" 389 keys = "SPC m c R"
374 command = "app.clock.report" 390 command = "app.clock.report"
375 mode = "normal" 391 mode = "normal"
@@ -830,6 +846,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
830 keys = "C-c C-x C-j" 846 keys = "C-c C-x C-j"
831 command = "app.clock.goto" 847 command = "app.clock.goto"
832 [[bind]] 848 [[bind]]
849 keys = "C-c C-x C-x"
850 command = "app.clock.in-last"
851 [[bind]]
852 keys = "C-c C-x C-z"
853 command = "app.clock.resolve"
854 [[bind]]
833 keys = "C-c C-e h h" 855 keys = "C-c C-e h h"
834 command = "app.export.html" 856 command = "app.export.html"
835 [[bind]] 857 [[bind]]
@@ -1260,6 +1282,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
1260 keys = "C-s-j" 1282 keys = "C-s-j"
1261 command = "app.clock.goto" 1283 command = "app.clock.goto"
1262 [[bind]] 1284 [[bind]]
1285 keys = "C-M-s-i"
1286 command = "app.clock.in-recent"
1287 [[bind]]
1288 keys = "C-M-s-j"
1289 command = "app.clock.goto-recent"
1290 [[bind]]
1263 keys = "s-S-e" 1291 keys = "s-S-e"
1264 command = "app.export-dialog" 1292 command = "app.export-dialog"
1265 [[bind]] 1293 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +5
@@ -87,9 +87,14 @@ public enum EditorCommands {
87 EditorCommand(id: "app.capture", title: "Capture…", action: .app), 87 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
88 EditorCommand(id: "app.board", title: "Board", action: .app), 88 EditorCommand(id: "app.board", title: "Board", action: .app),
89 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), 89 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app),
90 EditorCommand(id: "app.clock.in-recent", title: "Clock In to Recent Entry…", action: .app),
91 EditorCommand(id: "app.clock.in-last", title: "Clock In to Last Entry", action: .app),
90 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), 92 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app),
91 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), 93 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app),
92 EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), 94 EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app),
95 EditorCommand(id: "app.clock.goto-recent", title: "Go to Recent Clocked Entry…", action: .app),
96 EditorCommand(id: "app.clock.default", title: "Mark as Default Clock Task", action: .app),
97 EditorCommand(id: "app.clock.resolve", title: "Resolve Open Clocks…", action: .app),
93 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), 98 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app),
94 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), 99 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
95 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), 100 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
Sources/Orgstar/ClockViews.swift +71 −6
@@ -1,6 +1,8 @@
1import AppKit 1import AppKit
2import CoreGraphics
2import OrgApp 3import OrgApp
3import OrgCore 4import OrgCore
5import OrgEditorAppKit
4import SwiftUI 6import SwiftUI
5 7
6/// Elapsed time of the running clock, `H:MM`. 8/// Elapsed time of the running clock, `H:MM`.
@@ -9,6 +11,57 @@ func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
9 return String(format: "%d:%02d", minutes / 60, minutes % 60) 11 return String(format: "%d:%02d", minutes / 60, minutes % 60)
10} 12}
11 13
14/// What the menu bar item shows.
15struct ClockMenuState: Equatable {
16 let running: ClockModel.Running?
17 let history: [ClockTask]
18}
19
20/// `org-user-idle-seconds` on macOS: time since the last keyboard or mouse input anywhere.
21enum ClockIdle {
22 static func seconds() -> Double {
23 // `kCGAnyInputEventType`.
24 CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: CGEventType(rawValue: ~0)!)
25 }
26}
27
28/// The clock history in a menu: clocking in to an entry runs `app.clock.history.N`.
29struct RecentClocks: View {
30 let clock: ClockModel
31 let run: (String) -> Void
32
33 var body: some View {
34 if !clock.history.isEmpty {
35 Section("Recent Entries") {
36 ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in
37 Button(task.title) { run("app.clock.history.\(index)") }
38 .help("Clock in to \(task.title) in \((task.path as NSString).lastPathComponent)")
39 }
40 }
41 }
42 }
43}
44
45/// Org ▸ Clock: the clock commands, with their keys, and the recent entries.
46struct ClockMenu: View {
47 let commands: AppCommands
48 let clock: ClockModel
49
50 var body: some View {
51 Menu("Clock") {
52 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.clock.") }, id: \.id) { command in
53 Button {
54 commands.run(command.id)
55 } label: {
56 Text(command.title)
57 if let keys = commands.keyLabel(for: command.id) { Text(keys) }
58 }
59 }
60 RecentClocks(clock: clock) { commands.run($0) }
61 }
62 }
63}
64
12/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to. 65/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
13@MainActor 66@MainActor
14final class ClockStatusItem: NSObject { 67final class ClockStatusItem: NSObject {
@@ -21,7 +74,7 @@ final class ClockStatusItem: NSObject {
21 self.run = run 74 self.run = run
22 } 75 }
23 76
24 func update(_ running: ClockModel.Running?) { 77 func update(_ running: ClockModel.Running?, recent: [String] = []) {
25 self.running = running 78 self.running = running
26 guard let running else { 79 guard let running else {
27 timer?.invalidate() 80 timer?.invalidate()
@@ -39,16 +92,28 @@ final class ClockStatusItem: NSObject {
39 let menu = NSMenu() 92 let menu = NSMenu()
40 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "") 93 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
41 menu.addItem(.separator()) 94 menu.addItem(.separator())
42 for (title, id) in [("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"), ("Clock Report", "app.clock.report")] { 95 for (title, id) in [
43 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "") 96 ("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"),
44 entry.target = self 97 ("Clock In to Recent Entry…", "app.clock.in-recent"), ("Clock Report", "app.clock.report"),
45 entry.representedObject = id 98 ] {
46 menu.addItem(entry) 99 add(title, id, to: menu)
100 }
101 if !recent.isEmpty {
102 menu.addItem(.separator())
103 menu.addItem(NSMenuItem.sectionHeader(title: "Recent Entries"))
104 for (index, title) in recent.enumerated() { add(title, "app.clock.history.\(index)", to: menu) }
47 } 105 }
48 item?.menu = menu 106 item?.menu = menu
49 refresh() 107 refresh()
50 } 108 }
51 109
110 private func add(_ title: String, _ id: String, to menu: NSMenu) {
111 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "")
112 entry.target = self
113 entry.representedObject = id
114 menu.addItem(entry)
115 }
116
52 private func refresh() { 117 private func refresh() {
53 guard let running else { return } 118 guard let running else { return }
54 item?.button?.title = "⏱ " + clockElapsed(running, now: Date()) 119 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
Sources/Orgstar/ContentView.swift +62 −5
@@ -16,6 +16,7 @@ struct ContentView: View {
16 let clock: ClockModel 16 let clock: ClockModel
17 @AppStorage("reminders") private var remindersEnabled = true 17 @AppStorage("reminders") private var remindersEnabled = true
18 @AppStorage("reminderLead") private var reminderLead = 12 18 @AppStorage("reminderLead") private var reminderLead = 12
19 @AppStorage("clockIdleTime") private var clockIdleTime = 0
19 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 20 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
20 @State private var reminderAgenda: AgendaModel? 21 @State private var reminderAgenda: AgendaModel?
21 @State private var trust = TrustStore() 22 @State private var trust = TrustStore()
@@ -150,6 +151,10 @@ struct ContentView: View {
150 Button("Clock Out") { perform("app.clock.out") } 151 Button("Clock Out") { perform("app.clock.out") }
151 Button("Cancel Clock") { perform("app.clock.cancel") } 152 Button("Cancel Clock") { perform("app.clock.cancel") }
152 Button("Go to Clocked Entry") { perform("app.clock.goto") } 153 Button("Go to Clocked Entry") { perform("app.clock.goto") }
154 Divider()
155 Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
156 Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
157 RecentClocks(clock: clock) { perform($0) }
153 } label: { 158 } label: {
154 TimelineView(.periodic(from: .now, by: 30)) { context in 159 TimelineView(.periodic(from: .now, by: 30)) { context in
155 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1) 160 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
@@ -280,9 +285,9 @@ struct ContentView: View {
280 285
281 var body: some View { 286 var body: some View {
282 main 287 main
283 .onChange(of: clock.running, initial: true) { _, running in 288 .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in
284 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } } 289 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
285 statusItem?.update(running) 290 statusItem?.update(state.running, recent: state.history.map(\.title))
286 } 291 }
287 .onChange(of: globalCapture, initial: true) { _, enabled in 292 .onChange(of: globalCapture, initial: true) { _, enabled in
288 hotKey?.unregister() 293 hotKey?.unregister()
@@ -345,6 +350,18 @@ struct ContentView: View {
345 hour += 1 350 hour += 1
346 } 351 }
347 } 352 }
353 // `org-resolve-clocks-if-idle`, checked every minute while a clock runs.
354 .task(id: "\(clock.running?.start ?? "") \(clockIdleTime)") {
355 guard clock.running != nil, clockIdleTime > 0 else { return }
356 while !Task.isCancelled {
357 try? await Task.sleep(for: .seconds(60))
358 guard !Task.isCancelled, clock.running != nil, !clock.isResolving else { continue }
359 let idle = ClockIdle.seconds()
360 guard idle > Double(clockIdleTime * 60) else { continue }
361 NSApp.requestUserAttention(.informationalRequest)
362 report(await clock.resolveIdle(session, idleSince: Date().addingTimeInterval(-idle), ask: askClock))
363 }
364 }
348 .task { 365 .task {
349 DevelopmentHooks.scheduleSnapshot() 366 DevelopmentHooks.scheduleSnapshot()
350 pendingCarets = BufferRestore.restore(session) 367 pendingCarets = BufferRestore.restore(session)
@@ -523,7 +540,7 @@ struct ContentView: View {
523 540
524 /// Runs a command from a key, the palette or the Org menu. 541 /// Runs a command from a key, the palette or the Org menu.
525 private func perform(_ id: String) { 542 private func perform(_ id: String) {
526 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.") 543 let orgOnly = ["app.refile", "app.archive", "app.clock.in", "app.clock.default"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.")
527 if orgOnly, !session.isOrg { 544 if orgOnly, !session.isOrg {
528 commands.show("Not an org file") 545 commands.show("Not an org file")
529 return 546 return
@@ -620,13 +637,31 @@ struct ContentView: View {
620 case "app.agenda": 637 case "app.agenda":
621 openWindow(id: "agenda") 638 openWindow(id: "agenda")
622 case "app.clock.in": 639 case "app.clock.in":
623 commands.show(clock.clockIn(session)) 640 clockIn(.caret)
641 case "app.clock.in-recent":
642 clockIn(.select)
643 case "app.clock.in-last":
644 clockIn(.last)
645 case let id where id.hasPrefix("app.clock.history."):
646 if let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count { clockIn(.task(clock.history[index])) }
624 case "app.clock.out": 647 case "app.clock.out":
625 commands.show(clock.clockOut(session)) 648 commands.show(clock.clockOut(session))
626 case "app.clock.cancel": 649 case "app.clock.cancel":
627 commands.show(clock.clockOut(session, cancel: true)) 650 commands.show(clock.clockOut(session, cancel: true))
628 case "app.clock.goto": 651 case "app.clock.goto":
629 if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } 652 let found = clock.goto(session)
653 if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
654 if let message = found.message { commands.show(message) }
655 case "app.clock.goto-recent":
656 Task {
657 let found = await clock.gotoRecent(session, ask: askClock)
658 if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
659 if let message = found.message { commands.show(message) }
660 }
661 case "app.clock.default":
662 commands.show(clock.markDefault(session))
663 case "app.clock.resolve":
664 Task { report(await clock.resolveOpenClocks(session, paths: orgFiles, ask: askClock)) }
630 case "app.clock.report": 665 case "app.clock.report":
631 openWindow(id: "clock-report") 666 openWindow(id: "clock-report")
632 case "app.board": 667 case "app.board":
@@ -655,6 +690,28 @@ struct ContentView: View {
655 } 690 }
656 } 691 }
657 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 }
701
702 /// 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) }
704
705 private func clockIn(_ target: ClockModel.Target) {
706 Task { report(await clock.clockIn(session, target: target, paths: orgFiles, ask: askClock)) }
707 }
708
709 /// The last message of a clock command, and the clock to show for `j`.
710 private func report(_ outcome: ClockModel.Outcome) {
711 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
712 if let message = outcome.messages.last(where: { !$0.isEmpty }) { commands.show(message) }
713 }
714
658 private var errorShown: Binding<Bool> { 715 private var errorShown: Binding<Bool> {
659 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) 716 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
660 } 717 }
Sources/Orgstar/OrgstarApp.swift +18 −1
@@ -401,6 +401,8 @@ struct OrgstarApp: App {
401 } 401 }
402 .commands { 402 .commands {
403 CommandMenu("Org") { 403 CommandMenu("Org") {
404 ClockMenu(commands: commands, clock: clock)
405 Divider()
404 OrgMenuItems(commands: commands) 406 OrgMenuItems(commands: commands)
405 } 407 }
406 } 408 }
@@ -503,6 +505,8 @@ struct SettingsView: View {
503 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 505 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
504 @AppStorage("reminders") private var reminders = true 506 @AppStorage("reminders") private var reminders = true
505 @AppStorage("reminderLead") private var reminderLead = 12 507 @AppStorage("reminderLead") private var reminderLead = 12
508 @AppStorage("clockIdleTime") private var clockIdleTime = 0
509 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
506 @AppStorage("globalCapture") private var globalCapture = true 510 @AppStorage("globalCapture") private var globalCapture = true
507 @AppStorage("settingsTab") private var tab = "general" 511 @AppStorage("settingsTab") private var tab = "general"
508 @AppStorage("showHiddenFiles") private var showHiddenFiles = true 512 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -664,9 +668,22 @@ struct SettingsView: View {
664 Text("An entry's APPT_WARNTIME property overrides the lead time.") 668 Text("An entry's APPT_WARNTIME property overrides the lead time.")
665 .settingsFooter() 669 .settingsFooter()
666 } 670 }
671 Section {
672 Toggle("Ask what to do with idle time while clocked in", isOn: Binding {
673 clockIdleTime > 0
674 } set: { on in
675 clockIdleTime = on ? 15 : 0
676 })
677 Stepper("After \(max(clockIdleTime, 1)) minutes without keyboard or mouse input", value: $clockIdleTime, in: 1...240)
678 .disabled(clockIdleTime == 0)
679 Stepper("Remember \(clockHistoryLength) recently clocked entries", value: $clockHistoryLength, in: 1...35)
680 } footer: {
681 Text("org-clock-idle-time and org-clock-history-length. Org ▸ Clock lists the recent entries.")
682 .settingsFooter()
683 }
667 } 684 }
668 .formStyle(.grouped) 685 .formStyle(.grouped)
669 .frame(height: 290) 686 .frame(height: 430)
670 } 687 }
671 688
672 private var capture: some View { 689 private var capture: some View {