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 {
638638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
639639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
640640 "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",
642643 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
643644 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
644645 "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 {
370370 command = "app.clock.goto"
371371 mode = "normal"
372372 [[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]]
373389 keys = "SPC m c R"
374390 command = "app.clock.report"
375391 mode = "normal"
@@ -830,6 +846,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
830846 keys = "C-c C-x C-j"
831847 command = "app.clock.goto"
832848 [[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]]
833855 keys = "C-c C-e h h"
834856 command = "app.export.html"
835857 [[bind]]
@@ -1260,6 +1282,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
12601282 keys = "C-s-j"
12611283 command = "app.clock.goto"
12621284 [[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]]
12631291 keys = "s-S-e"
12641292 command = "app.export-dialog"
12651293 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +5
@@ -87,9 +87,14 @@ public enum EditorCommands {
8787 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
8888 EditorCommand(id: "app.board", title: "Board", action: .app),
8989 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),
9092 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app),
9193 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app),
9294 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),
9398 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app),
9499 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
95100 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
Sources/Orgstar/ClockViews.swift +71 −6
@@ -1,6 +1,8 @@
11import AppKit
2import CoreGraphics
23import OrgApp
34import OrgCore
5import OrgEditorAppKit
46import SwiftUI
57
68/// Elapsed time of the running clock, `H:MM`.
@@ -9,6 +11,57 @@ func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
911 return String(format: "%d:%02d", minutes / 60, minutes % 60)
1012}
1113
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
1265/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
1366@MainActor
1467final class ClockStatusItem: NSObject {
@@ -21,7 +74,7 @@ final class ClockStatusItem: NSObject {
2174 self.run = run
2275 }
2376
24 func update(_ running: ClockModel.Running?) {
77 func update(_ running: ClockModel.Running?, recent: [String] = []) {
2578 self.running = running
2679 guard let running else {
2780 timer?.invalidate()
@@ -39,16 +92,28 @@ final class ClockStatusItem: NSObject {
3992 let menu = NSMenu()
4093 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
4194 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")] {
43 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "")
44 entry.target = self
45 entry.representedObject = id
46 menu.addItem(entry)
95 for (title, id) in [
96 ("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"),
97 ("Clock In to Recent Entry…", "app.clock.in-recent"), ("Clock Report", "app.clock.report"),
98 ] {
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) }
47105 }
48106 item?.menu = menu
49107 refresh()
50108 }
51109
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
52117 private func refresh() {
53118 guard let running else { return }
54119 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
Sources/Orgstar/ContentView.swift +62 −5
@@ -16,6 +16,7 @@ struct ContentView: View {
1616 let clock: ClockModel
1717 @AppStorage("reminders") private var remindersEnabled = true
1818 @AppStorage("reminderLead") private var reminderLead = 12
19 @AppStorage("clockIdleTime") private var clockIdleTime = 0
1920 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
2021 @State private var reminderAgenda: AgendaModel?
2122 @State private var trust = TrustStore()
@@ -150,6 +151,10 @@ struct ContentView: View {
150151 Button("Clock Out") { perform("app.clock.out") }
151152 Button("Cancel Clock") { perform("app.clock.cancel") }
152153 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) }
153158 } label: {
154159 TimelineView(.periodic(from: .now, by: 30)) { context in
155160 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
@@ -280,9 +285,9 @@ struct ContentView: View {
280285
281286 var body: some View {
282287 main
283 .onChange(of: clock.running, initial: true) { _, running in
288 .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in
284289 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
285 statusItem?.update(running)
290 statusItem?.update(state.running, recent: state.history.map(\.title))
286291 }
287292 .onChange(of: globalCapture, initial: true) { _, enabled in
288293 hotKey?.unregister()
@@ -345,6 +350,18 @@ struct ContentView: View {
345350 hour += 1
346351 }
347352 }
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 }
348365 .task {
349366 DevelopmentHooks.scheduleSnapshot()
350367 pendingCarets = BufferRestore.restore(session)
@@ -523,7 +540,7 @@ struct ContentView: View {
523540
524541 /// Runs a command from a key, the palette or the Org menu.
525542 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.")
527544 if orgOnly, !session.isOrg {
528545 commands.show("Not an org file")
529546 return
@@ -620,13 +637,31 @@ struct ContentView: View {
620637 case "app.agenda":
621638 openWindow(id: "agenda")
622639 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])) }
624647 case "app.clock.out":
625648 commands.show(clock.clockOut(session))
626649 case "app.clock.cancel":
627650 commands.show(clock.clockOut(session, cancel: true))
628651 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)) }
630665 case "app.clock.report":
631666 openWindow(id: "clock-report")
632667 case "app.board":
@@ -655,6 +690,28 @@ struct ContentView: View {
655690 }
656691 }
657692
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
658715 private var errorShown: Binding<Bool> {
659716 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
660717 }
Sources/Orgstar/OrgstarApp.swift +18 −1
@@ -401,6 +401,8 @@ struct OrgstarApp: App {
401401 }
402402 .commands {
403403 CommandMenu("Org") {
404 ClockMenu(commands: commands, clock: clock)
405 Divider()
404406 OrgMenuItems(commands: commands)
405407 }
406408 }
@@ -503,6 +505,8 @@ struct SettingsView: View {
503505 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
504506 @AppStorage("reminders") private var reminders = true
505507 @AppStorage("reminderLead") private var reminderLead = 12
508 @AppStorage("clockIdleTime") private var clockIdleTime = 0
509 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
506510 @AppStorage("globalCapture") private var globalCapture = true
507511 @AppStorage("settingsTab") private var tab = "general"
508512 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -664,9 +668,22 @@ struct SettingsView: View {
664668 Text("An entry's APPT_WARNTIME property overrides the lead time.")
665669 .settingsFooter()
666670 }
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 }
667684 }
668685 .formStyle(.grouped)
669 .frame(height: 290)
686 .frame(height: 430)
670687 }
671688
672689 private var capture: some View {