krz/orgstar

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

Commit af140c9251

af140c9251eac8434e572e9dda587c4573b64e6b

parent: bd4320d167

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 06:17 UTC

Agenda view

Window ▸ Agenda (⇧⌘A, C-c a): days with category, time, leader,
TODO keyword, title and tags; f/b/. move the span, Return or a double
click jumps to the entry. Reads the top-level org files of each
folder, the open file from its buffer. Settings for span, start day
and subfolders.

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

Layout: unified · split

Sources/OrgApp/AgendaModel.swift added +93
@@ -0,0 +1,93 @@
1import Foundation
2import Observation
3import OrgCore
4import OrgIndex
5
6/// The day/week agenda over the workspace's agenda files. Sources are read from disk and
7/// cached by modification date and size; the open file's come from its buffer, so the agenda
8/// shows unsaved edits.
9@MainActor
10@Observable
11public final class AgendaModel {
12 public private(set) var days: [AgendaDay] = []
13 /// The first day shown, as `Days` numbers it; nil follows today.
14 public var start: Int?
15 public var span = 10
16 /// Where the view starts relative to today, as Doom's `org-agenda-start-day "-3d"`.
17 public var startOffset = -3
18 /// Files below the top level of each folder are agenda files too.
19 public var includeSubfolders = false
20 public var options = AgendaOptions()
21 public private(set) var isComputing = false
22
23 private let workspace: WorkspaceModel
24 @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:]
25 @ObservationIgnored private var generation = 0
26
27 public init(workspace: WorkspaceModel) {
28 self.workspace = workspace
29 }
30
31 /// Org files directly in each folder (or below it, with `includeSubfolders`), by path, as
32 /// Emacs lists a directory in `org-agenda-files`.
33 public var files: [String] {
34 workspace.files
35 .filter { $0.kind == .org && (includeSubfolders || !$0.relativePath.contains("/")) }
36 .map(\.path)
37 .sorted()
38 }
39
40 /// Recomputes the days. `open` is the open file's path and text.
41 public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async {
42 generation += 1
43 let id = generation
44 isComputing = true
45 let today = Days.absolute(now, calendar: calendar)
46 let clock = calendar.dateComponents([.hour, .minute], from: now)
47 let first = start ?? today + startOffset
48 let defaults = workspace.settings.org
49 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
50 for path in files {
51 if let open, open.path == path {
52 reads.append((path, nil, .distantPast, -1, open.text))
53 continue
54 }
55 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
56 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
57 let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1
58 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
59 reads.append((path, cached, mtime, size, nil))
60 }
61 let span = self.span
62 let options = self.options
63 let result = await Task.detached {
64 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
65 for read in reads {
66 if let cached = read.cached {
67 sources.append((read.path, read.mtime, read.size, cached, true))
68 } else if let text = read.text {
69 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false))
70 } else if let data = FileManager.default.contents(atPath: read.path) {
71 let text = SourceText(bytes: [UInt8](data)).text
72 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
73 }
74 }
75 let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
76 return (sources, days)
77 }.value
78 guard id == generation else { return }
79 for source in result.0 where source.fromDisk {
80 cache[source.path] = (source.mtime, source.size, source.source)
81 }
82 days = result.1
83 isComputing = false
84 }
85
86 public func showToday() { start = nil }
87
88 /// Moves the view by a whole span, forward or back.
89 public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) {
90 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
91 start = current + (forward ? span : -span)
92 }
93}
Sources/OrgApp/DocumentSession.swift +8 −1
@@ -53,6 +53,9 @@ public final class DocumentSession {
5353 /// keystroke; views follow `path`, `isDirty` and `outline`.
5454 @ObservationIgnored public private(set) var document: DocumentState?
5555 public private(set) var outline: [OutlineEntry] = []
56 /// Bumped when the buffer settles after edits, and when a file opens: views that read
57 /// `document` follow this.
58 public private(set) var editVersion = 0
5659 public private(set) var isDirty = false
5760 /// The file isn't UTF-8, so it can't be edited or saved.
5861 public private(set) var isReadOnly = false
@@ -117,6 +120,7 @@ public final class DocumentSession {
117120 try? await Task.sleep(for: .milliseconds(300))
118121 guard !Task.isCancelled, let self, let document = self.document else { return }
119122 self.outline = Presentation.outline(document.tree)
123 self.editVersion += 1
120124 }
121125 scheduleAutosave()
122126 }
@@ -127,7 +131,10 @@ public final class DocumentSession {
127131 document = state
128132 if isDirty != state.isDirty { isDirty = state.isDirty }
129133 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
130 if updateOutline { outline = Presentation.outline(state.tree) }
134 if updateOutline {
135 outline = Presentation.outline(state.tree)
136 editVersion += 1
137 }
131138 }
132139
133140 // MARK: - Saving
Sources/OrgCore/Keymap/Presets.swift +3
@@ -150,6 +150,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
150150 [[bind]]
151151 keys = "M-x"
152152 command = "app.palette"
153 [[bind]]
154 keys = "C-c a"
155 command = "app.agenda"
153156
154157 # Org
155158 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
4242 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
4343 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
4444 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
4546
4647 movement("forward-char", "Forward Character", "moveForward:"),
4748 movement("backward-char", "Backward Character", "moveBackward:"),
Sources/Orgstar/AgendaView.swift added +190
@@ -0,0 +1,190 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps
6/// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes.
7struct AgendaView: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 let commands: AppCommands
11 @State private var agenda: AgendaModel
12 @State private var selection: String?
13 @State private var tick = 0
14 @AppStorage("agendaSpan") private var span = 10
15 @AppStorage("agendaStartOffset") private var startOffset = -3
16 @AppStorage("agendaSubfolders") private var includeSubfolders = false
17 @Environment(\.openWindow) private var openWindow
18
19 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
20 self.workspace = workspace
21 self.session = session
22 self.commands = commands
23 _agenda = State(initialValue: AgendaModel(workspace: workspace))
24 }
25
26 private struct RefreshKey: Hashable {
27 var start: Int?
28 var span: Int
29 var startOffset: Int
30 var includeSubfolders: Bool
31 var files: Int
32 var changed: Int?
33 var edits: Int
34 var path: String?
35 var tick: Int
36 }
37
38 private var refreshKey: RefreshKey {
39 RefreshKey(
40 start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
41 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
42 path: session.path, tick: tick
43 )
44 }
45
46 var body: some View {
47 List(selection: $selection) {
48 ForEach(agenda.days, id: \.day) { day in
49 Section {
50 ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in
51 AgendaRow(item: item)
52 .tag(Self.id(day, index))
53 .selectionDisabled(item.path == nil)
54 }
55 } header: {
56 Text(day.header)
57 .font(.headline)
58 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
59 }
60 }
61 }
62 .contextMenu(forSelectionType: String.self) { _ in
63 } primaryAction: { ids in
64 if let id = ids.first { jump(id) }
65 }
66 .onKeyPress(.return) {
67 guard let selection else { return .ignored }
68 jump(selection)
69 return .handled
70 }
71 .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in
72 switch press.characters {
73 case "f": agenda.shift(forward: true)
74 case "b": agenda.shift(forward: false)
75 case ".": agenda.showToday()
76 default: tick += 1
77 }
78 return .handled
79 }
80 .toolbar {
81 ToolbarItemGroup {
82 if agenda.isComputing { ProgressView().controlSize(.small) }
83 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
84 .help("Previous \(span) days (b)")
85 Button("Today") { agenda.showToday() }
86 .help("Back to today (.)")
87 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
88 .help("Next \(span) days (f)")
89 }
90 }
91 .overlay {
92 if agenda.files.isEmpty {
93 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
94 }
95 }
96 .navigationTitle("Agenda")
97 .task(id: refreshKey) {
98 agenda.span = span
99 agenda.startOffset = startOffset
100 agenda.includeSubfolders = includeSubfolders
101 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
102 await agenda.refresh(open: open)
103 }
104 .task {
105 // The time grid's "now" line and the day itself move on.
106 while !Task.isCancelled {
107 try? await Task.sleep(for: .seconds(60))
108 tick += 1
109 }
110 }
111 }
112
113 private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" }
114
115 private func jump(_ id: String) {
116 let parts = id.split(separator: ":").compactMap { Int($0) }
117 guard parts.count == 2, let day = agenda.days.first(where: { $0.day == parts[0] }), parts[1] < day.items.count else { return }
118 let item = day.items[parts[1]]
119 guard let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
120 commands.jump(path: path, offset: offset)
121 openWindow(id: "main")
122 }
123}
124
125struct AgendaRow: View {
126 let item: AgendaItem
127
128 var body: some View {
129 switch item.kind {
130 case .timeGrid:
131 HStack(spacing: 8) {
132 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
133 .monospacedDigit()
134 .frame(width: 140, alignment: .trailing)
135 VStack { Divider() }
136 }
137 .font(.caption)
138 .foregroundStyle(.tertiary)
139 case .currentTime:
140 HStack(spacing: 8) {
141 Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
142 .monospacedDigit()
143 .frame(width: 140, alignment: .trailing)
144 Rectangle().fill(Color.accentColor).frame(height: 1)
145 }
146 .font(.caption)
147 .foregroundStyle(Color.accentColor)
148 default:
149 HStack(spacing: 8) {
150 Text(item.category)
151 .lineLimit(1)
152 .foregroundStyle(.secondary)
153 .frame(width: 80, alignment: .leading)
154 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
155 .monospacedDigit()
156 .frame(width: 80, alignment: .leading)
157 if !item.extra.isEmpty {
158 Text(item.extra.trimmingCharacters(in: .whitespaces))
159 .monospacedDigit()
160 .foregroundStyle(leaderColor)
161 }
162 title
163 .lineLimit(1)
164 Spacer(minLength: 8)
165 if !item.tags.isEmpty {
166 Text(":" + item.tags.joined(separator: ":") + ":")
167 .font(.caption)
168 .foregroundStyle(.secondary)
169 }
170 }
171 }
172 }
173
174 private var leaderColor: Color {
175 switch item.kind {
176 case .deadline: .red
177 case .upcomingDeadline: .orange
178 case .pastScheduled: .brown
179 default: .secondary
180 }
181 }
182
183 /// The TODO keyword colored as in the editor.
184 private var title: Text {
185 guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) }
186 let text = item.text.trimmingCharacters(in: .whitespaces)
187 let rest = String(text.dropFirst(todo.count))
188 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
189 }
190}
Sources/Orgstar/ContentView.swift +14
@@ -29,6 +29,7 @@ struct ContentView: View {
2929 @State private var selectedFile: String?
3030 @State private var query = ""
3131 @FocusState private var searchFocused: Bool
32 @Environment(\.openWindow) private var openWindow
3233
3334 var body: some View {
3435 NavigationSplitView {
@@ -101,6 +102,15 @@ struct ContentView: View {
101102 .onChange(of: workspace.changedFile?.version) {
102103 if let path = workspace.changedFile?.path { session.fileChanged(path) }
103104 }
105 .onChange(of: commands.jumpRequest?.id) {
106 guard let request = commands.jumpRequest else { return }
107 if request.path != session.path {
108 guard UnsavedChanges.resolve(session) else { return }
109 session.open(request.path)
110 selectedFile = request.path
111 }
112 session.reveal(request.offset)
113 }
104114 .onChange(of: commands.focusSearch) { _, focus in
105115 if focus {
106116 searchFocused = true
@@ -130,6 +140,8 @@ struct ContentView: View {
130140 .task {
131141 DevelopmentHooks.scheduleSnapshot()
132142 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
143 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
144 if let id = DevelopmentHooks.windowToOpen { openWindow(id: id) }
133145 await workspace.refresh()
134146 workspace.startWatching()
135147 }
@@ -163,6 +175,8 @@ struct ContentView: View {
163175 }
164176 case "app.archive":
165177 if let message = session.archive() { commands.show(message) }
178 case "app.agenda":
179 openWindow(id: "agenda")
166180 default:
167181 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
168182 }
Sources/Orgstar/DevelopmentHooks.swift +7 −1
@@ -3,11 +3,15 @@ import AppKit
33/// Switches for checking the app from scripts, read from the environment:
44/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
55/// - `ORGSTAR_OPEN`: a file to select at launch.
6/// - `ORGSTAR_ROOT`: a folder to add at launch.
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`); the snapshot renders it.
68/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
79/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
810/// permission.
911enum DevelopmentHooks {
1012 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
13 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
14 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
1115
1216 @MainActor
1317 static func scheduleSnapshot() {
@@ -15,7 +19,9 @@ enum DevelopmentHooks {
1519 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
1620 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
1721 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
18 if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview,
22 let visible = NSApp.windows.filter(\.isVisible)
23 let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.hasPrefix(id) == true } } ?? visible.first
24 if let view = window?.contentView?.superview,
1925 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
2026 view.cacheDisplay(in: view.bounds, to: bitmap)
2127 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
Sources/Orgstar/OrgstarApp.swift +34
@@ -13,6 +13,9 @@ final class AppCommands {
1313 var focusSearch = false
1414 var showingConflict = false
1515 var showingPalette = false
16 /// A location another window asked the main window to show.
17 private(set) var jumpRequest: (path: String, offset: Int, id: Int)?
18 private var jumpID = 0
1619 /// The editor showing the open file, for menus and the palette.
1720 var editor: OrgEditor?
1821 /// The pending key prefix, and what can follow it.
@@ -36,6 +39,11 @@ final class AppCommands {
3639 request.reply(text)
3740 }
3841
42 func jump(path: String, offset: Int) {
43 jumpID += 1
44 jumpRequest = (path, offset, jumpID)
45 }
46
3947 func show(_ text: String) {
4048 messageID += 1
4149 let id = messageID
@@ -111,6 +119,12 @@ struct OrgstarApp: App {
111119 }
112120 }
113121 .defaultSize(width: 1280, height: 860)
122 Window("Agenda", id: "agenda") {
123 if case .success(let model) = workspace {
124 AgendaView(workspace: model, session: session, commands: commands)
125 }
126 }
127 .defaultSize(width: 760, height: 720)
114128 .commands {
115129 CommandGroup(after: .newItem) {
116130 Button("Add Folder…") { commands.showingFolderPicker = true }
@@ -131,6 +145,9 @@ struct OrgstarApp: App {
131145 Button("Search Notes") { commands.focusSearch = true }
132146 .keyboardShortcut("f", modifiers: [.command, .shift])
133147 }
148 CommandGroup(before: .windowList) {
149 AgendaMenuItem()
150 }
134151 }
135152 .commands {
136153 CommandMenu("Org") {
@@ -146,6 +163,16 @@ struct OrgstarApp: App {
146163 }
147164}
148165
166/// Window ▸ Agenda.
167struct AgendaMenuItem: View {
168 @Environment(\.openWindow) private var openWindow
169
170 var body: some View {
171 Button("Agenda") { openWindow(id: "agenda") }
172 .keyboardShortcut("a", modifiers: [.command, .shift])
173 }
174}
175
149176struct SettingsView: View {
150177 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
151178 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -158,6 +185,9 @@ struct SettingsView: View {
158185 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
159186 @AppStorage("prettyEntities") private var prettyEntities = true
160187 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
188 @AppStorage("agendaSpan") private var agendaSpan = 10
189 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
190 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
161191
162192 var body: some View {
163193 Form {
@@ -197,6 +227,10 @@ struct SettingsView: View {
197227 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
198228 .font(.caption)
199229 .foregroundStyle(.secondary)
230 Divider()
231 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
232 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
233 Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders)
200234 }
201235 .padding(20)
202236 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +33
@@ -273,3 +273,36 @@ enum StateBufferCaret {
273273 session.attach(buffer)
274274 }
275275}
276
277@MainActor
278struct AgendaModelTests {
279 @Test func topLevelFilesAndTheOpenBuffer() async throws {
280 let notes = try Folder()
281 try notes.write("a.org", "* TODO Pay rent\nSCHEDULED: <2026-10-05 Mon>\n")
282 try notes.write("sub/b.org", "* Hidden <2026-10-05 Mon>\n")
283 try notes.write("old.org_archive", "* Archived <2026-10-05 Mon>\n")
284 let model = try WorkspaceModel(directory: nil)
285 try await model.addRoot(notes.url)
286 let agenda = AgendaModel(workspace: model)
287 var utc = Calendar(identifier: .gregorian)
288 utc.timeZone = TimeZone(identifier: "UTC")!
289 let now = try #require(utc.date(from: DateComponents(year: 2026, month: 10, day: 5, hour: 9)))
290
291 await agenda.refresh(open: nil, now: now, calendar: utc)
292 #expect(agenda.days.count == 10)
293 #expect(agenda.days[3].isToday)
294 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent"])
295
296 agenda.includeSubfolders = true
297 await agenda.refresh(open: nil, now: now, calendar: utc)
298 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent", "Hidden "])
299
300 let path = try #require(agenda.files.first { $0.hasSuffix("/a.org") })
301 await agenda.refresh(open: (path, "* DONE Pay rent\nSCHEDULED: <2026-10-05 Mon>\n"), now: now, calendar: utc)
302 #expect(agenda.days[3].items.first?.text == "DONE Pay rent")
303
304 agenda.shift(forward: true, now: now, calendar: utc)
305 await agenda.refresh(open: nil, now: now, calendar: utc)
306 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12))
307 }
308}
docs/plans/2026-10-05-agenda.md +7 −7
@@ -24,13 +24,13 @@
2424
2525### Task 1: Engine
2626
27- [ ] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`.
28- [ ] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers.
29- [ ] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span.
30- [ ] Commit "Agenda engine".
27- [x] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`.
28- [x] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers.
29- [x] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span.
30- [x] Commit "Agenda engine".
3131
3232### Task 2: App
3333
34- [ ] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits.
35- [ ] Settings: span, start offset, agenda folders.
36- [ ] Commit "Agenda view".
34- [x] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits.
35- [x] Settings: span, start offset, agenda folders.
36- [x] Commit "Agenda view".