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 {
53 /// keystroke; views follow `path`, `isDirty` and `outline`. 53 /// keystroke; views follow `path`, `isDirty` and `outline`.
54 @ObservationIgnored public private(set) var document: DocumentState? 54 @ObservationIgnored public private(set) var document: DocumentState?
55 public private(set) var outline: [OutlineEntry] = [] 55 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
56 public private(set) var isDirty = false 59 public private(set) var isDirty = false
57 /// The file isn't UTF-8, so it can't be edited or saved. 60 /// The file isn't UTF-8, so it can't be edited or saved.
58 public private(set) var isReadOnly = false 61 public private(set) var isReadOnly = false
@@ -117,6 +120,7 @@ public final class DocumentSession {
117 try? await Task.sleep(for: .milliseconds(300)) 120 try? await Task.sleep(for: .milliseconds(300))
118 guard !Task.isCancelled, let self, let document = self.document else { return } 121 guard !Task.isCancelled, let self, let document = self.document else { return }
119 self.outline = Presentation.outline(document.tree) 122 self.outline = Presentation.outline(document.tree)
123 self.editVersion += 1
120 } 124 }
121 scheduleAutosave() 125 scheduleAutosave()
122 } 126 }
@@ -127,7 +131,10 @@ public final class DocumentSession {
127 document = state 131 document = state
128 if isDirty != state.isDirty { isDirty = state.isDirty } 132 if isDirty != state.isDirty { isDirty = state.isDirty }
129 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } 133 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 }
131 } 138 }
132 139
133 // MARK: - Saving 140 // MARK: - Saving
Sources/OrgCore/Keymap/Presets.swift +3
@@ -150,6 +150,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
150 [[bind]] 150 [[bind]]
151 keys = "M-x" 151 keys = "M-x"
152 command = "app.palette" 152 command = "app.palette"
153 [[bind]]
154 keys = "C-c a"
155 command = "app.agenda"
153 156
154 # Org 157 # Org
155 [[bind]] 158 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), 42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app), 43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
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 46
46 movement("forward-char", "Forward Character", "moveForward:"), 47 movement("forward-char", "Forward Character", "moveForward:"),
47 movement("backward-char", "Backward Character", "moveBackward:"), 48 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 {
29 @State private var selectedFile: String? 29 @State private var selectedFile: String?
30 @State private var query = "" 30 @State private var query = ""
31 @FocusState private var searchFocused: Bool 31 @FocusState private var searchFocused: Bool
32 @Environment(\.openWindow) private var openWindow
32 33
33 var body: some View { 34 var body: some View {
34 NavigationSplitView { 35 NavigationSplitView {
@@ -101,6 +102,15 @@ struct ContentView: View {
101 .onChange(of: workspace.changedFile?.version) { 102 .onChange(of: workspace.changedFile?.version) {
102 if let path = workspace.changedFile?.path { session.fileChanged(path) } 103 if let path = workspace.changedFile?.path { session.fileChanged(path) }
103 } 104 }
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 }
104 .onChange(of: commands.focusSearch) { _, focus in 114 .onChange(of: commands.focusSearch) { _, focus in
105 if focus { 115 if focus {
106 searchFocused = true 116 searchFocused = true
@@ -130,6 +140,8 @@ struct ContentView: View {
130 .task { 140 .task {
131 DevelopmentHooks.scheduleSnapshot() 141 DevelopmentHooks.scheduleSnapshot()
132 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 142 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) }
133 await workspace.refresh() 145 await workspace.refresh()
134 workspace.startWatching() 146 workspace.startWatching()
135 } 147 }
@@ -163,6 +175,8 @@ struct ContentView: View {
163 } 175 }
164 case "app.archive": 176 case "app.archive":
165 if let message = session.archive() { commands.show(message) } 177 if let message = session.archive() { commands.show(message) }
178 case "app.agenda":
179 openWindow(id: "agenda")
166 default: 180 default:
167 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } 181 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
168 } 182 }
Sources/Orgstar/DevelopmentHooks.swift +7 −1
@@ -3,11 +3,15 @@ import AppKit
3/// Switches for checking the app from scripts, read from the environment: 3/// Switches for checking the app from scripts, read from the environment:
4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). 4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
5/// - `ORGSTAR_OPEN`: a file to select at launch. 5/// - `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.
6/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 8/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
7/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 9/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
8/// permission. 10/// permission.
9enum DevelopmentHooks { 11enum DevelopmentHooks {
10 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } 12 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"] }
11 15
12 @MainActor 16 @MainActor
13 static func scheduleSnapshot() { 17 static func scheduleSnapshot() {
@@ -15,7 +19,9 @@ enum DevelopmentHooks {
15 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } 19 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
16 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 20 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
17 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 21 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,
19 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { 25 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
20 view.cacheDisplay(in: view.bounds, to: bitmap) 26 view.cacheDisplay(in: view.bounds, to: bitmap)
21 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) 27 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
Sources/Orgstar/OrgstarApp.swift +34
@@ -13,6 +13,9 @@ final class AppCommands {
13 var focusSearch = false 13 var focusSearch = false
14 var showingConflict = false 14 var showingConflict = false
15 var showingPalette = false 15 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
16 /// The editor showing the open file, for menus and the palette. 19 /// The editor showing the open file, for menus and the palette.
17 var editor: OrgEditor? 20 var editor: OrgEditor?
18 /// The pending key prefix, and what can follow it. 21 /// The pending key prefix, and what can follow it.
@@ -36,6 +39,11 @@ final class AppCommands {
36 request.reply(text) 39 request.reply(text)
37 } 40 }
38 41
42 func jump(path: String, offset: Int) {
43 jumpID += 1
44 jumpRequest = (path, offset, jumpID)
45 }
46
39 func show(_ text: String) { 47 func show(_ text: String) {
40 messageID += 1 48 messageID += 1
41 let id = messageID 49 let id = messageID
@@ -111,6 +119,12 @@ struct OrgstarApp: App {
111 } 119 }
112 } 120 }
113 .defaultSize(width: 1280, height: 860) 121 .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)
114 .commands { 128 .commands {
115 CommandGroup(after: .newItem) { 129 CommandGroup(after: .newItem) {
116 Button("Add Folder…") { commands.showingFolderPicker = true } 130 Button("Add Folder…") { commands.showingFolderPicker = true }
@@ -131,6 +145,9 @@ struct OrgstarApp: App {
131 Button("Search Notes") { commands.focusSearch = true } 145 Button("Search Notes") { commands.focusSearch = true }
132 .keyboardShortcut("f", modifiers: [.command, .shift]) 146 .keyboardShortcut("f", modifiers: [.command, .shift])
133 } 147 }
148 CommandGroup(before: .windowList) {
149 AgendaMenuItem()
150 }
134 } 151 }
135 .commands { 152 .commands {
136 CommandMenu("Org") { 153 CommandMenu("Org") {
@@ -146,6 +163,16 @@ struct OrgstarApp: App {
146 } 163 }
147} 164}
148 165
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
149struct SettingsView: View { 176struct SettingsView: View {
150 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 177 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
151 @AppStorage("keymap") private var preset = KeymapPreset.emacs 178 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -158,6 +185,9 @@ struct SettingsView: View {
158 @AppStorage("hiddenMarkers") private var hiddenMarkers = true 185 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
159 @AppStorage("prettyEntities") private var prettyEntities = true 186 @AppStorage("prettyEntities") private var prettyEntities = true
160 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords 187 @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
161 191
162 var body: some View { 192 var body: some View {
163 Form { 193 Form {
@@ -197,6 +227,10 @@ struct SettingsView: View {
197 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") 227 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
198 .font(.caption) 228 .font(.caption)
199 .foregroundStyle(.secondary) 229 .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)
200 } 234 }
201 .padding(20) 235 .padding(20)
202 .frame(width: 480) 236 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +33
@@ -273,3 +273,36 @@ enum StateBufferCaret {
273 session.attach(buffer) 273 session.attach(buffer)
274 } 274 }
275} 275}
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 @@
24 24
25### Task 1: Engine 25### Task 1: Engine
26 26
27- [ ] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`. 27- [x] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`.
28- [ ] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers. 28- [x] `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. 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- [ ] Commit "Agenda engine". 30- [x] Commit "Agenda engine".
31 31
32### Task 2: App 32### Task 2: App
33 33
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. 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- [ ] Settings: span, start offset, agenda folders. 35- [x] Settings: span, start offset, agenda folders.
36- [ ] Commit "Agenda view". 36- [x] Commit "Agenda view".