krz/orgstar

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

Commit d3f871e3e7

d3f871e3e72aad75d7fcdc8bd2d828ecec0a4a3f

parent: 7cc38fb006

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 08:01 UTC

Table and kanban views

The Board window shows agenda-file entries matching a tags/property
query as a sortable table with property columns, or as kanban columns
by TODO keyword where dropping a card sets its keyword. SetTodo and
SetProperty match org-todo and org-entry-put.

Ref plan docs/plans/2026-10-05-board.md

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +6
@@ -82,6 +82,12 @@ public final class AgendaModel {
82 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value 82 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value
83 } 83 }
84 84
85 /// Entries for the table and kanban views, and the keywords their files use.
86 public func board(open: (path: String, text: String)?, match: String) async -> (entries: [BoardEntry], keywords: [String]) {
87 let all = await sources(open: open)
88 return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value
89 }
90
85 /// The agenda files' sources: the open file's from its text, the others from the cache 91 /// The agenda files' sources: the open file's from its text, the others from the cache
86 /// while their modification date and size are unchanged. 92 /// while their modification date and size are unchanged.
87 private func sources(open: (path: String, text: String)?) async -> [AgendaSource] { 93 private func sources(open: (path: String, text: String)?) async -> [AgendaSource] {
Sources/OrgCore/Agenda/Board.swift added +58
@@ -0,0 +1,58 @@
1import Foundation
2
3/// An entry as the table and kanban views show it.
4public struct BoardEntry: Sendable, Equatable, Identifiable {
5 public let path: String
6 public let headingOffset: Int
7 /// The heading line, to check the offset still holds before editing.
8 public let headingLine: String
9 public let title: String
10 public let todo: String?
11 public let isDone: Bool
12 public let priority: String?
13 public let tags: [String]
14 public let category: String
15 /// Planning timestamps without brackets.
16 public let scheduled: String?
17 public let deadline: String?
18 /// Upper-cased keys of the entry's own drawer.
19 public let properties: [String: String]
20
21 public var id: String { "\(path)#\(headingOffset)" }
22}
23
24public enum Board {
25 /// Entries of `sources` that match `match` (a tags/property/TODO match, as for `m` in the
26 /// agenda), or every entry with a TODO keyword when `match` is empty.
27 public static func entries(_ sources: [AgendaSource], match: String, now: Date = Date(), calendar: Calendar = .current) -> [BoardEntry] {
28 let trimmed = match.trimmingCharacters(in: .whitespaces)
29 let matcher = trimmed.isEmpty ? nil : TagsMatcher(trimmed)
30 var result: [BoardEntry] = []
31 for source in sources {
32 for heading in source.headings where !heading.skipped {
33 if let matcher {
34 guard matcher.matches(heading, in: source, now: now, calendar: calendar) else { continue }
35 } else if heading.todo == nil {
36 continue
37 }
38 result.append(BoardEntry(
39 path: source.path, headingOffset: heading.start, headingLine: heading.line, title: heading.title,
40 todo: heading.todo, isDone: heading.isDone, priority: heading.priority, tags: heading.tags.map(\.name),
41 category: heading.category, scheduled: heading.scheduled?.raw, deadline: heading.deadline?.raw,
42 properties: heading.properties
43 ))
44 }
45 }
46 return result
47 }
48
49 /// The keywords for kanban columns, in sequence order: each file's keywords, first come
50 /// first.
51 public static func keywords(_ sources: [AgendaSource]) -> [String] {
52 var seen: [String] = []
53 for source in sources {
54 for keyword in source.keywords where !seen.contains(keyword) { seen.append(keyword) }
55 }
56 return seen
57 }
58}
Sources/OrgCore/Commands/Properties.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2
3/// `org-set-property` / `org-entry-put` for one entry.
4public struct SetProperty: OrgCommand {
5 public let key: String
6 public let value: String
7 public init(key: String, value: String) {
8 self.key = key
9 self.value = value
10 }
11 public var id: String { "org.property.set" }
12 public var title: String { "Set Property" }
13 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
14 public func run(in context: EditContext) -> CommandStep {
15 if key.uppercased() == "TODO" { return SetTodo(keyword: value.isEmpty ? nil : value).run(in: context) }
16 guard key.range(of: "^[^\\s:]+$", options: .regularExpression) != nil else { return .failed("Invalid property name: \"\(key)\"") }
17 return commitBuffer(context) { buffer in
18 buffer.saveExcursion { buffer.entryPut(key, value) }
19 }
20 }
21}
22
23/// `org-todo` with a keyword (nil for none), with the statistics and repeat it brings.
24public struct SetTodo: OrgCommand {
25 public let keyword: String?
26 public init(keyword: String?) { self.keyword = keyword }
27 public var id: String { "org.todo.set" }
28 public var title: String { "Set TODO Keyword" }
29 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
30 public func run(in context: EditContext) -> CommandStep {
31 guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
32 let settings = context.tree.settings
33 if let keyword, !settings.todoSequences.contains(where: { ($0.active + $0.done).contains { $0.name == keyword } }) {
34 return .failed("\"\(keyword)\" is not a valid TODO state")
35 }
36 return commitBuffer(context) { buffer in
37 try buffer.saveExcursion {
38 let previous = buffer.todoKeyword
39 buffer.setTodoKeyword(keyword)
40 guard previous != keyword else { return }
41 if let keyword, settings.isDone(keyword), !(previous.map(settings.isDone) ?? false),
42 let (marker, note) = try buffer.autoRepeat(doneWord: keyword, previous: previous) {
43 buffer.storeNote(marker, note)
44 }
45 }
46 }
47 }
48}
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -44,6 +44,7 @@ public enum EditorCommands {
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), 44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app), 45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
46 EditorCommand(id: "app.capture", title: "Capture…", action: .app), 46 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
47 EditorCommand(id: "app.board", title: "Board", action: .app),
47 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), 48 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app),
48 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), 49 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app),
49 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), 50 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app),
Sources/Orgstar/BoardView.swift added +229
@@ -0,0 +1,229 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4import UniformTypeIdentifiers
5
6/// Entries of the agenda files as a sortable table with property columns, or as a kanban
7/// board by TODO keyword. Dragging a card to a column sets its keyword; edits go through the
8/// open buffer or the file.
9struct BoardView: View {
10 let workspace: WorkspaceModel
11 let session: DocumentSession
12 let commands: AppCommands
13 @State private var agenda: AgendaModel
14 @State private var entries: [BoardEntry] = []
15 @State private var keywords: [String] = []
16 @State private var selection: Set<String> = []
17 @State private var sortOrder = [KeyPathComparator(\BoardRow.todoOrder), KeyPathComparator(\BoardRow.title)]
18 @State private var message: String?
19 @State private var tick = 0
20 @AppStorage("boardKanban") private var kanban = false
21 @AppStorage("boardMatch") private var match = ""
22 @AppStorage("boardColumns") private var columnText = "EFFORT"
23 @AppStorage("agendaSubfolders") private var includeSubfolders = false
24 @Environment(\.openWindow) private var openWindow
25
26 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
27 self.workspace = workspace
28 self.session = session
29 self.commands = commands
30 _agenda = State(initialValue: AgendaModel(workspace: workspace))
31 }
32
33 private var propertyColumns: [String] {
34 columnText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).uppercased() }.filter { !$0.isEmpty }
35 }
36
37 private var rows: [BoardRow] {
38 entries.map { BoardRow(entry: $0, todoOrder: $0.todo.flatMap { keywords.firstIndex(of: $0) } ?? Int.max) }.sorted(using: sortOrder)
39 }
40
41 private struct RefreshKey: Hashable {
42 var match: String
43 var subfolders: Bool
44 var files: Int
45 var changed: Int?
46 var edits: Int
47 var path: String?
48 var tick: Int
49 }
50
51 var body: some View {
52 Group {
53 if kanban { board } else { table }
54 }
55 .safeAreaInset(edge: .bottom, spacing: 0) {
56 if let message {
57 Text(message).font(.callout).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 6).background(.bar)
58 }
59 }
60 .toolbar {
61 ToolbarItemGroup(placement: .navigation) {
62 Picker("View", selection: $kanban) {
63 Label("Table", systemImage: "tablecells").tag(false)
64 Label("Kanban", systemImage: "rectangle.split.3x1").tag(true)
65 }
66 .pickerStyle(.segmented)
67 TextField("Match: +work/!TODO", text: $match)
68 .frame(minWidth: 200)
69 if !kanban {
70 TextField("Property columns", text: $columnText)
71 .frame(minWidth: 140)
72 .help("Property names to show as columns, separated by commas")
73 }
74 }
75 }
76 .navigationTitle(kanban ? "Kanban" : "Entries")
77 .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) {
78 agenda.includeSubfolders = includeSubfolders
79 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
80 let result = await agenda.board(open: open, match: match)
81 entries = result.entries
82 keywords = result.keywords
83 }
84 }
85
86 // MARK: - Table
87
88 private var table: some View {
89 Table(rows, selection: $selection, sortOrder: $sortOrder) {
90 TableColumn("TODO", value: \.todoOrder) { row in
91 Text(row.entry.todo ?? "").foregroundStyle(row.entry.isDone ? .green : .red).bold()
92 }
93 .width(min: 50, ideal: 60, max: 90)
94 TableColumn("P", value: \.priority) { row in Text(row.priority) }
95 .width(min: 20, ideal: 24, max: 40)
96 TableColumn("Title", value: \.title) { row in Text(row.title).lineLimit(1) }
97 .width(min: 160, ideal: 320)
98 TableColumn("Tags", value: \.tags) { row in Text(row.tags).foregroundStyle(.secondary).lineLimit(1) }
99 .width(min: 60, ideal: 120)
100 TableColumn("Scheduled", value: \.scheduled) { row in Text(row.scheduled).monospacedDigit() }
101 .width(min: 80, ideal: 110)
102 TableColumn("Deadline", value: \.deadline) { row in Text(row.deadline).monospacedDigit() }
103 .width(min: 80, ideal: 110)
104 TableColumnForEach(propertyColumns, id: \.self) { key in
105 TableColumn(key.capitalized) { (row: BoardRow) in Text(row.entry.properties[key] ?? "") }
106 .width(min: 50, ideal: 80)
107 }
108 TableColumn("File", value: \.file) { row in Text(row.file).foregroundStyle(.secondary).lineLimit(1) }
109 .width(min: 60, ideal: 120)
110 }
111 .contextMenu(forSelectionType: String.self) { ids in
112 if let entry = entries.first(where: { ids.contains($0.id) }) {
113 Menu("Set TODO") {
114 ForEach(keywords, id: \.self) { keyword in Button(keyword) { setTodo(entry, keyword) } }
115 Divider()
116 Button("None") { setTodo(entry, nil) }
117 }
118 Button("Set Property…") { askProperty(entry) }
119 Button("Open") { jump(entry) }
120 }
121 } primaryAction: { ids in
122 if let entry = entries.first(where: { ids.contains($0.id) }) { jump(entry) }
123 }
124 }
125
126 // MARK: - Kanban
127
128 private var board: some View {
129 ScrollView(.horizontal) {
130 HStack(alignment: .top, spacing: 12) {
131 ForEach(keywords, id: \.self) { keyword in
132 column(keyword)
133 }
134 }
135 .padding(12)
136 }
137 }
138
139 private func column(_ keyword: String) -> some View {
140 let cards = entries.filter { $0.todo == keyword }
141 return VStack(alignment: .leading, spacing: 8) {
142 HStack {
143 Text(keyword).font(.headline)
144 Text("\(cards.count)").foregroundStyle(.secondary)
145 }
146 ScrollView {
147 VStack(alignment: .leading, spacing: 6) {
148 ForEach(cards) { entry in
149 card(entry)
150 .draggable(entry.id)
151 .onTapGesture(count: 2) { jump(entry) }
152 }
153 }
154 }
155 }
156 .frame(width: 240, alignment: .topLeading)
157 .frame(maxHeight: .infinity, alignment: .top)
158 .padding(8)
159 .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08)))
160 .dropDestination(for: String.self) { ids, _ in
161 for id in ids {
162 if let entry = entries.first(where: { $0.id == id }), entry.todo != keyword { setTodo(entry, keyword) }
163 }
164 return true
165 }
166 }
167
168 private func card(_ entry: BoardEntry) -> some View {
169 VStack(alignment: .leading, spacing: 4) {
170 Text((entry.priority.map { "[#\($0)] " } ?? "") + entry.title).lineLimit(3)
171 HStack(spacing: 6) {
172 Text(entry.category).font(.caption).foregroundStyle(.secondary)
173 if !entry.tags.isEmpty {
174 Text(entry.tags.joined(separator: " ")).font(.caption).foregroundStyle(.secondary).lineLimit(1)
175 }
176 }
177 if let deadline = entry.deadline {
178 Text("Deadline \(deadline)").font(.caption).foregroundStyle(.red)
179 } else if let scheduled = entry.scheduled {
180 Text("Scheduled \(scheduled)").font(.caption).foregroundStyle(.secondary)
181 }
182 }
183 .padding(8)
184 .frame(maxWidth: .infinity, alignment: .leading)
185 .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .controlBackgroundColor)))
186 }
187
188 // MARK: - Editing
189
190 private func perform(_ command: any OrgCommand, on entry: BoardEntry) {
191 switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine)) {
192 case .failed(let text): message = text
193 default: message = nil
194 }
195 tick += 1
196 }
197
198 private func setTodo(_ entry: BoardEntry, _ keyword: String?) {
199 perform(SetTodo(keyword: keyword), on: entry)
200 }
201
202 private func askProperty(_ entry: BoardEntry) {
203 commands.ask(Prompt(key: "property", message: "Property (NAME value):")) { answer in
204 guard let answer, let space = answer.firstIndex(of: " ") else { return }
205 let key = String(answer[..<space])
206 let value = String(answer[answer.index(after: space)...]).trimmingCharacters(in: .whitespaces)
207 perform(SetProperty(key: key, value: value), on: entry)
208 }
209 openWindow(id: "main")
210 }
211
212 private func jump(_ entry: BoardEntry) {
213 commands.jump(path: entry.path, offset: entry.headingOffset)
214 openWindow(id: "main")
215 }
216}
217
218/// A table row with sortable values.
219struct BoardRow: Identifiable {
220 let entry: BoardEntry
221 let todoOrder: Int
222 var id: String { entry.id }
223 var title: String { entry.title }
224 var priority: String { entry.priority ?? "" }
225 var tags: String { entry.tags.joined(separator: " ") }
226 var scheduled: String { entry.scheduled ?? "" }
227 var deadline: String { entry.deadline ?? "" }
228 var file: String { (entry.path as NSString).lastPathComponent }
229}
Sources/Orgstar/ContentView.swift +2
@@ -343,6 +343,8 @@ struct ContentView: View {
343 if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } 343 if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") }
344 case "app.clock.report": 344 case "app.clock.report":
345 openWindow(id: "clock-report") 345 openWindow(id: "clock-report")
346 case "app.board":
347 openWindow(id: "board")
346 case "app.capture": 348 case "app.capture":
347 NSApp.activate() 349 NSApp.activate()
348 openWindow(id: "capture") 350 openWindow(id: "capture")
Sources/Orgstar/OrgstarApp.swift +8
@@ -162,6 +162,12 @@ struct OrgstarApp: App {
162 } 162 }
163 } 163 }
164 .defaultSize(width: 760, height: 720) 164 .defaultSize(width: 760, height: 720)
165 Window("Board", id: "board") {
166 if case .success(let model) = workspace {
167 BoardView(workspace: model, session: session, commands: commands)
168 }
169 }
170 .defaultSize(width: 1100, height: 700)
165 Window("Clock Report", id: "clock-report") { 171 Window("Clock Report", id: "clock-report") {
166 if case .success(let model) = workspace { 172 if case .success(let model) = workspace {
167 ClockReportView(workspace: model) 173 ClockReportView(workspace: model)
@@ -232,6 +238,8 @@ struct AgendaMenuItem: View {
232 var body: some View { 238 var body: some View {
233 Button("Agenda") { openWindow(id: "agenda") } 239 Button("Agenda") { openWindow(id: "agenda") }
234 .keyboardShortcut("a", modifiers: [.command, .shift]) 240 .keyboardShortcut("a", modifiers: [.command, .shift])
241 Button("Board") { openWindow(id: "board") }
242 .keyboardShortcut("b", modifiers: [.command, .shift])
235 } 243 }
236} 244}
237 245
Tests/OrgCoreTests/PropertyTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct PropertyTests {
6 static let texts = [
7 "* TODO Task\n",
8 "* TODO Task\nBody\n",
9 "* Task\nSCHEDULED: <2026-10-05 Mon>\nBody\n",
10 "* Task\n:PROPERTIES:\n:OWNER: alice\n:SIZE: 3\n:END:\nBody\n",
11 "* Task :tag:\n:PROPERTIES:\n:ID: x\n:END:\n** Child\n",
12 "* DONE Old\n* TODO Repeat\nSCHEDULED: <2026-10-05 Mon +1w>\n",
13 "* Task",
14 ]
15
16 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
17 func matchesEmacs() throws {
18 let edits: [(any OrgCommand, String)] = [
19 (SetProperty(key: "OWNER", value: "bob"), "(org-entry-put nil \"OWNER\" \"bob\")"),
20 (SetProperty(key: "Effort", value: "1:30"), "(org-entry-put nil \"Effort\" \"1:30\")"),
21 (SetProperty(key: "LONGPROPERTYNAME", value: "v"), "(org-entry-put nil \"LONGPROPERTYNAME\" \"v\")"),
22 (SetProperty(key: "SIZE", value: ""), "(org-entry-put nil \"SIZE\" \"\")"),
23 (SetTodo(keyword: "DONE"), "(org-todo \"DONE\")"),
24 (SetTodo(keyword: "TODO"), "(org-todo \"TODO\")"),
25 (SetTodo(keyword: nil), "(org-todo 'none)"),
26 ]
27 var cases: [EmacsOracle.Case] = []
28 var ours: [(text: String, caret: Int, failure: String?)] = []
29 for text in Self.texts {
30 for heading in [(text as NSString).range(of: "Task").location, (text as NSString).range(of: "Repeat").location] where heading != NSNotFound {
31 for (command, form) in edits {
32 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: frozenClock(1_789_000_000, "(progn \(form) (run-hooks 'post-command-hook))")))
33 var utc = Calendar(identifier: .gregorian)
34 utc.timeZone = TimeZone(identifier: "UTC")!
35 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [heading..<heading], now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc)
36 switch command.run(in: context) {
37 case .commit(let result):
38 var new = text
39 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
40 ours.append((new, heading, nil))
41 case .failed(let message): ours.append((text, heading, message))
42 default: ours.append((text, heading, "other"))
43 }
44 }
45 }
46 }
47 let emacs = try EmacsOracle.run(cases)
48 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
49 #expect(mine.text == theirs.text, "\(cases[i].form.split(separator: "\n").last ?? "") on \(cases[i].text.debugDescription)\nours: \(mine.text.debugDescription) \(mine.failure ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
50 }
51 }
52}
53
54struct BoardTests {
55 @Test func entriesAndKeywords() {
56 let source = AgendaSource(path: "/n/a.org", text: """
57 #+TODO: TODO NEXT | DONE
58 * TODO [#A] One :work:
59 :PROPERTIES:
60 :EFFORT: 1:00
61 :END:
62 * Plain
63 * DONE Two
64 DEADLINE: <2026-10-05 Mon>
65 * ARCHIVED TODO :ARCHIVE:
66 ** TODO Hidden
67 """)
68 let all = Board.entries([source], match: "")
69 #expect(all.map(\.title) == ["One", "Two"])
70 #expect(all[0].properties["EFFORT"] == "1:00" && all[0].priority == "A" && all[0].tags == ["work"])
71 #expect(all[1].deadline == "2026-10-05 Mon")
72 #expect(Board.entries([source], match: "+work").map(\.title) == ["One"])
73 #expect(Board.keywords([source]) == ["TODO", "NEXT", "DONE"])
74 }
75}
docs/plans/2026-10-05-board.md added +17
@@ -0,0 +1,17 @@
1# Table and Kanban Views Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Entries of the agenda files as a sortable table with chosen property columns, and as a kanban board by TODO keyword, both editing the files.
6
7**Architecture:** `Board.entries` selects headings from the agenda sources with the agenda's tags/property match (or every entry with a keyword), carrying TODO, priority, tags, category, planning and own properties. `SetTodo` (`org-todo` with a keyword, with its repeat handling) and `SetProperty` (`org-entry-put`) write changes, checked against Emacs. The Board window (⇧⌘B) shows a `Table` with sortable columns and property columns named in the toolbar, or kanban columns per keyword where dropping a card sets its keyword; edits go through the open buffer or the save path and are refused if the heading moved.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Phase 6".
12
13---
14
15- [x] `Board`, `SetTodo`, `SetProperty`; oracle and tests.
16- [x] Board window: table, kanban, context menu, drag to set keyword.
17- [x] Commit "Table and kanban views".