krz/orgstar

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

Commit daac3706d5

daac3706d5aa523bc907d6118edd28cbc0767283

parent: 5a1ebf5bef

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 07:11 UTC

Add a columns and clock inspector

An inspector beside the editor (⌥⌘I) shows the open file's column view, for
the file or the subtree at the caret, and its clocked time per heading for
today, this week or all time. Both follow unsaved edits; clicking a row goes
to its heading.

Ref docs/review-2026-10-05.md tier 3 item 5

Layout: unified · split

Sources/OrgCore/Commands/ClockReport.swift +15 −2
@@ -8,6 +8,8 @@ public enum ClockReport {
8 /// The heading's title, without keyword, priority, tags or COMMENT. 8 /// The heading's title, without keyword, priority, tags or COMMENT.
9 public let heading: String 9 public let heading: String
10 public let minutes: Int 10 public let minutes: Int
11 /// Where the heading starts.
12 public let start: Int
11 } 13 }
12 14
13 static let clockLine = try! NSRegularExpression(pattern: 15 static let clockLine = try! NSRegularExpression(pattern:
@@ -32,12 +34,23 @@ public enum ClockReport {
32 var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces) 34 var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces)
33 if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) } 35 if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) }
34 let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))! 36 let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))!
35 result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes)) 37 result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes, start: starts[low - 1]))
36 } 38 }
37 return result 39 return result
38 } 40 }
39 41
40 static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } 42 /// Minutes per heading between `from` and `to` (inclusive, `YYYY-MM-DD`), in file order.
43 public static func byHeading(_ entries: [Entry], from: String? = nil, to: String? = nil) -> [(start: Int, heading: String, minutes: Int)] {
44 var totals: [Int: (heading: String, minutes: Int)] = [:]
45 for entry in entries {
46 if let from, entry.date < from { continue }
47 if let to, entry.date > to { continue }
48 totals[entry.start, default: (entry.heading, 0)].minutes += entry.minutes
49 }
50 return totals.keys.sorted().map { (start: $0, heading: totals[$0]!.heading, minutes: totals[$0]!.minutes) }
51 }
52
53 public static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) }
41 54
42 /// Emacs `format "%-Ns"` pads by characters. 55 /// Emacs `format "%-Ns"` pads by characters.
43 static func pad(_ s: String, _ width: Int) -> String { 56 static func pad(_ s: String, _ width: Int) -> String {
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -69,6 +69,7 @@ public enum EditorCommands {
69 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), 69 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
70 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 70 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
71 EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), 71 EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app),
72 EditorCommand(id: "app.toggle-inspector", title: "Show or Hide Columns and Clock", action: .app),
72 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 73 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
73 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), 74 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
74 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app), 75 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app),
Sources/Orgstar/ContentView.swift +6
@@ -69,6 +69,7 @@ struct ContentView: View {
69 @AppStorage("outlineShown") private var orgOutlineShown = true 69 @AppStorage("outlineShown") private var orgOutlineShown = true
70 @AppStorage("outlineWidth") private var outlineWidth = 220.0 70 @AppStorage("outlineWidth") private var outlineWidth = 220.0
71 @AppStorage("backlinksShown") private var backlinksShown = true 71 @AppStorage("backlinksShown") private var backlinksShown = true
72 @AppStorage("inspectorShown") private var inspectorShown = false
72 @AppStorage("showMarkup") private var showMarkup = false 73 @AppStorage("showMarkup") private var showMarkup = false
73 @AppStorage("showLineNumbers") private var showLineNumbers = true 74 @AppStorage("showLineNumbers") private var showLineNumbers = true
74 @AppStorage("showHiddenFiles") private var showHiddenFiles = true 75 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -196,6 +197,10 @@ struct ContentView: View {
196 } 197 }
197 detail 198 detail
198 .frame(maxWidth: .infinity) 199 .frame(maxWidth: .infinity)
200 .inspector(isPresented: $inspectorShown) {
201 InspectorPane(session: session, commands: commands)
202 .inspectorColumnWidth(min: 240, ideal: 340, max: 600)
203 }
199 } 204 }
200 } 205 }
201 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar") 206 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
@@ -547,6 +552,7 @@ struct ContentView: View {
547 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } 552 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") }
548 case "app.toggle-outline": toggleOutline() 553 case "app.toggle-outline": toggleOutline()
549 case "app.toggle-backlinks": backlinksShown.toggle() 554 case "app.toggle-backlinks": backlinksShown.toggle()
555 case "app.toggle-inspector": inspectorShown.toggle()
550 case "app.toggle-markup": showMarkup.toggle() 556 case "app.toggle-markup": showMarkup.toggle()
551 case "app.toggle-line-numbers": showLineNumbers.toggle() 557 case "app.toggle-line-numbers": showLineNumbers.toggle()
552 case "app.import-emacs": showingImport = true 558 case "app.import-emacs": showingImport = true
Sources/Orgstar/InspectorPane.swift added +140
@@ -0,0 +1,140 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// The inspector beside the editor: column view and clocked time for the open file, kept
6/// current as it changes.
7struct InspectorPane: View {
8 let session: DocumentSession
9 let commands: AppCommands
10 @AppStorage("inspectorTab") private var tab = Tab.columns
11 @AppStorage("inspectorColumnsScope") private var subtree = false
12 @AppStorage("inspectorClockSpan") private var span = Span.week
13 @State private var columns: ColumnView.View?
14 @State private var clocked: [(start: Int, heading: String, minutes: Int)] = []
15
16 enum Tab: String, CaseIterable { case columns = "Columns", clock = "Clock" }
17 enum Span: String, CaseIterable { case today = "Today", week = "This Week", all = "All" }
18
19 private struct Key: Equatable {
20 var path: String?
21 var version: Int
22 var caret: Int?
23 var subtree: Bool
24 var span: Span
25 }
26
27 /// The heading at the caret, for a subtree's columns and the current row.
28 private var heading: Int? { session.outline.last { $0.start <= commands.selection.location }?.start }
29
30 var body: some View {
31 VStack(spacing: 0) {
32 Picker("", selection: $tab) {
33 ForEach(Tab.allCases, id: \.self) { Text($0.rawValue) }
34 }
35 .pickerStyle(.segmented)
36 .labelsHidden()
37 .padding(8)
38 Divider()
39 switch tab {
40 case .columns: columnsView
41 case .clock: clockView
42 }
43 }
44 .task(id: Key(path: session.path, version: session.editVersion, caret: subtree ? heading : nil, subtree: subtree, span: span)) { refresh() }
45 }
46
47 @ViewBuilder
48 private var columnsView: some View {
49 Picker("Scope", selection: $subtree) {
50 Text("File").tag(false)
51 Text("Subtree").tag(true)
52 }
53 .pickerStyle(.segmented)
54 .padding(.horizontal, 8)
55 .padding(.bottom, 6)
56 if let columns, !columns.rows.isEmpty {
57 ScrollView([.horizontal, .vertical]) {
58 Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 3) {
59 GridRow {
60 ForEach(columns.specs.indices, id: \.self) { i in
61 Text(columns.specs[i].title).bold().foregroundStyle(.secondary)
62 }
63 }
64 Divider()
65 ForEach(columns.rows.indices, id: \.self) { r in
66 let row = columns.rows[r]
67 GridRow {
68 ForEach(columns.specs.indices, id: \.self) { i in
69 Text(columns.specs[i].property == "ITEM" ? String(repeating: "*", count: row.level) + " " + row.values[i] : row.values[i])
70 .lineLimit(1)
71 .fontWeight(row.start == heading ? .semibold : .regular)
72 }
73 }
74 .contentShape(Rectangle())
75 .onTapGesture { session.reveal(row.start) }
76 }
77 }
78 .font(.system(.callout, design: .monospaced))
79 .padding(8)
80 }
81 .defaultScrollAnchor(.topLeading)
82 } else {
83 ContentUnavailableView("No entries", systemImage: "tablecells")
84 }
85 }
86
87 @ViewBuilder
88 private var clockView: some View {
89 Picker("Span", selection: $span) {
90 ForEach(Span.allCases, id: \.self) { Text($0.rawValue) }
91 }
92 .pickerStyle(.segmented)
93 .padding(.horizontal, 8)
94 .padding(.bottom, 6)
95 if clocked.isEmpty {
96 ContentUnavailableView("No clocked time", systemImage: "clock")
97 } else {
98 List {
99 ForEach(clocked, id: \.start) { entry in
100 HStack {
101 Text(entry.heading.isEmpty ? "(untitled)" : entry.heading).lineLimit(1)
102 Spacer()
103 Text(ClockReport.hours(entry.minutes)).monospacedDigit()
104 }
105 .fontWeight(entry.start == heading ? .semibold : .regular)
106 .contentShape(Rectangle())
107 .onTapGesture { session.reveal(entry.start) }
108 }
109 HStack {
110 Text("Total").bold()
111 Spacer()
112 Text(ClockReport.hours(clocked.reduce(0) { $0 + $1.minutes })).bold().monospacedDigit()
113 }
114 }
115 }
116 }
117
118 private func refresh() {
119 guard session.isOrg, let document = session.document else {
120 columns = nil
121 clocked = []
122 return
123 }
124 let text = document.text
125 columns = ColumnView.capture(text: text, tree: document.tree, at: heading ?? 0, global: !subtree || heading == nil).view
126 let formatter = DateFormatter()
127 formatter.locale = Locale(identifier: "en_US_POSIX")
128 formatter.dateFormat = "yyyy-MM-dd"
129 // Weeks start on Monday, as org's do.
130 var calendar = Calendar.current
131 calendar.firstWeekday = 2
132 let today = Date()
133 let from: Date? = switch span {
134 case .today: today
135 case .week: calendar.dateInterval(of: .weekOfYear, for: today)?.start
136 case .all: nil
137 }
138 clocked = ClockReport.byHeading(ClockReport.entries(text), from: from.map(formatter.string), to: span == .all ? nil : formatter.string(from: today))
139 }
140}
Sources/Orgstar/OrgstarApp.swift +2
@@ -350,6 +350,8 @@ struct OrgstarApp: App {
350 CommandGroup(after: .sidebar) { 350 CommandGroup(after: .sidebar) {
351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
352 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } 352 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
353 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
354 .keyboardShortcut("i", modifiers: [.command, .option])
353 .keyboardShortcut("o", modifiers: [.command, .option]) 355 .keyboardShortcut("o", modifiers: [.command, .option])
354 MarkupToggle() 356 MarkupToggle()
355 } 357 }
Tests/OrgCoreTests/ClockTests.swift +8
@@ -88,6 +88,14 @@ struct ClockReportTests {
88 :END: 88 :END:
89 """ 89 """
90 90
91 @Test func totalsPerHeading() {
92 let entries = ClockReport.entries(Self.sample)
93 let all = ClockReport.byHeading(entries)
94 #expect(all.map(\.heading) == ["Audit work", "Meetings"] && all.map(\.minutes) == [418, 57])
95 #expect(all[1].start == (Self.sample as NSString).range(of: "* Meetings").location)
96 #expect(ClockReport.byHeading(entries, from: "2026-02-18", to: "2026-02-24").map(\.minutes) == [360, 30])
97 }
98
91 /// Your own `tr/generate-report`, loaded from config.el in batch Emacs. 99 /// Your own `tr/generate-report`, loaded from config.el in batch Emacs.
92 static func emacsReport(_ text: String, start: String?, end: String?) throws -> String { 100 static func emacsReport(_ text: String, start: String?, end: String?) throws -> String {
93 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)") 101 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)")