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 {
88 /// The heading's title, without keyword, priority, tags or COMMENT.
99 public let heading: String
1010 public let minutes: Int
11 /// Where the heading starts.
12 public let start: Int
1113 }
1214
1315 static let clockLine = try! NSRegularExpression(pattern:
@@ -32,12 +34,23 @@ public enum ClockReport {
3234 var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces)
3335 if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) }
3436 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]))
3638 }
3739 return result
3840 }
3941
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) }
4154
4255 /// Emacs `format "%-Ns"` pads by characters.
4356 static func pad(_ s: String, _ width: Int) -> String {
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -69,6 +69,7 @@ public enum EditorCommands {
6969 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
7070 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
7171 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),
7273 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
7374 EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app),
7475 EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app),
Sources/Orgstar/ContentView.swift +6
@@ -69,6 +69,7 @@ struct ContentView: View {
6969 @AppStorage("outlineShown") private var orgOutlineShown = true
7070 @AppStorage("outlineWidth") private var outlineWidth = 220.0
7171 @AppStorage("backlinksShown") private var backlinksShown = true
72 @AppStorage("inspectorShown") private var inspectorShown = false
7273 @AppStorage("showMarkup") private var showMarkup = false
7374 @AppStorage("showLineNumbers") private var showLineNumbers = true
7475 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -196,6 +197,10 @@ struct ContentView: View {
196197 }
197198 detail
198199 .frame(maxWidth: .infinity)
200 .inspector(isPresented: $inspectorShown) {
201 InspectorPane(session: session, commands: commands)
202 .inspectorColumnWidth(min: 240, ideal: 340, max: 600)
203 }
199204 }
200205 }
201206 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
@@ -547,6 +552,7 @@ struct ContentView: View {
547552 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") }
548553 case "app.toggle-outline": toggleOutline()
549554 case "app.toggle-backlinks": backlinksShown.toggle()
555 case "app.toggle-inspector": inspectorShown.toggle()
550556 case "app.toggle-markup": showMarkup.toggle()
551557 case "app.toggle-line-numbers": showLineNumbers.toggle()
552558 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 {
350350 CommandGroup(after: .sidebar) {
351351 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
352352 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])
353355 .keyboardShortcut("o", modifiers: [.command, .option])
354356 MarkupToggle()
355357 }
Tests/OrgCoreTests/ClockTests.swift +8
@@ -88,6 +88,14 @@ struct ClockReportTests {
8888 :END:
8989 """
9090
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
9199 /// Your own `tr/generate-report`, loaded from config.el in batch Emacs.
92100 static func emacsReport(_ text: String, start: String?, end: String?) throws -> String {
93101 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)")