krz/orgstar

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

Commit d5d6891199

d5d689119906f897a828c3446b186ad97c0a37ea

parent: 042e2f8aa2

Verified · cmc

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

Agenda views and commands

The agenda window switches between the agenda, the TODO list, tag
matches and views from views.toml. In it, t, +, -, :, C-c C-s,
C-c C-d, C-c C-q and S-left/right change the entry: through the
buffer when its file is open, otherwise written to the file through
the save path. A heading that changed since the agenda was built is
refused.

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

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +29 −3
@@ -10,6 +10,9 @@ import OrgIndex
10@Observable 10@Observable
11public final class AgendaModel { 11public final class AgendaModel {
12 public private(set) var days: [AgendaDay] = [] 12 public private(set) var days: [AgendaDay] = []
13 /// The entries of a TODO list or match view.
14 public private(set) var list: [AgendaItem] = []
15 public var mode: AgendaMode = .agenda(span: nil, startOffset: nil)
13 /// The first day shown, as `Days` numbers it; nil follows today. 16 /// The first day shown, as `Days` numbers it; nil follows today.
14 public var start: Int? 17 public var start: Int?
15 public var span = 10 18 public var span = 10
@@ -44,6 +47,12 @@ public final class AgendaModel {
44 isComputing = true 47 isComputing = true
45 let today = Days.absolute(now, calendar: calendar) 48 let today = Days.absolute(now, calendar: calendar)
46 let clock = calendar.dateComponents([.hour, .minute], from: now) 49 let clock = calendar.dateComponents([.hour, .minute], from: now)
50 var span = self.span
51 var startOffset = self.startOffset
52 if case .agenda(let viewSpan, let viewStart) = mode {
53 span = viewSpan ?? span
54 startOffset = viewStart ?? startOffset
55 }
47 let first = start ?? today + startOffset 56 let first = start ?? today + startOffset
48 let defaults = workspace.settings.org 57 let defaults = workspace.settings.org
49 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] 58 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
@@ -58,8 +67,8 @@ public final class AgendaModel {
58 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } 67 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
59 reads.append((path, cached, mtime, size, nil)) 68 reads.append((path, cached, mtime, size, nil))
60 } 69 }
61 let span = self.span
62 let options = self.options 70 let options = self.options
71 let mode = self.mode
63 let result = await Task.detached { 72 let result = await Task.detached {
64 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] 73 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
65 for read in reads { 74 for read in reads {
@@ -72,14 +81,25 @@ public final class AgendaModel {
72 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) 81 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
73 } 82 }
74 } 83 }
75 let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) 84 let all = sources.map(\.source)
76 return (sources, days) 85 var days: [AgendaDay] = []
86 var list: [AgendaItem] = []
87 switch mode {
88 case .agenda:
89 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
90 case .todo(let keywords):
91 list = Agenda.todoList(all, keywords: keywords)
92 case .match(let match, let todoOnly):
93 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar)
94 }
95 return (sources, days, list)
77 }.value 96 }.value
78 guard id == generation else { return } 97 guard id == generation else { return }
79 for source in result.0 where source.fromDisk { 98 for source in result.0 where source.fromDisk {
80 cache[source.path] = (source.mtime, source.size, source.source) 99 cache[source.path] = (source.mtime, source.size, source.source)
81 } 100 }
82 days = result.1 101 days = result.1
102 list = result.2
83 isComputing = false 103 isComputing = false
84 } 104 }
85 105
@@ -87,6 +107,12 @@ public final class AgendaModel {
87 107
88 /// Moves the view by a whole span, forward or back. 108 /// Moves the view by a whole span, forward or back.
89 public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) { 109 public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) {
110 var span = self.span
111 var startOffset = self.startOffset
112 if case .agenda(let viewSpan, let viewStart) = mode {
113 span = viewSpan ?? span
114 startOffset = viewStart ?? startOffset
115 }
90 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset 116 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
91 start = current + (forward ? span : -span) 117 start = current + (forward ? span : -span)
92 } 118 }
Sources/OrgApp/AgendaViews.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import OrgCore
3
4/// What the agenda window shows.
5public enum AgendaMode: Hashable, Sendable {
6 /// `org-agenda-list`; nil span and start use the settings.
7 case agenda(span: Int?, startOffset: Int?)
8 /// `org-todo-list`, optionally for `KWD1|KWD2`.
9 case todo(keywords: String?)
10 /// `org-tags-view`.
11 case match(String, todoOnly: Bool)
12}
13
14/// A named view: the built-in ones and those in `views.toml`.
15public struct AgendaViewDefinition: Hashable, Sendable, Identifiable {
16 public let name: String
17 public let mode: AgendaMode
18 public var id: String { name }
19
20 public init(name: String, mode: AgendaMode) {
21 self.name = name
22 self.mode = mode
23 }
24}
25
26/// Reads `views.toml`:
27///
28/// ```toml
29/// [[view]]
30/// name = "Work"
31/// type = "match" # agenda, todo, match or todo-match
32/// match = "+work-boss"
33/// ```
34///
35/// `agenda` views take `span` and `start` (days from today), `todo` views `keywords`
36/// (`WAIT|HOLD`).
37@MainActor
38public enum AgendaViewLoader {
39 public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("views.toml") }
40
41 public static let builtIn = [
42 AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)),
43 AgendaViewDefinition(name: "TODO List", mode: .todo(keywords: nil)),
44 ]
45
46 /// The built-in views followed by the user's, and the problems found in the file.
47 public static func load(userFile: URL = userFile) -> (views: [AgendaViewDefinition], problems: [String]) {
48 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (builtIn, []) }
49 let tables: [TOML.Table]
50 do {
51 tables = try TOML.parse(text)
52 } catch {
53 return (builtIn, ["views.toml: \(error)"])
54 }
55 var views = builtIn
56 var problems: [String] = []
57 for table in tables where !(table.name.isEmpty && table.values.isEmpty) {
58 guard table.name == "view", table.isArrayElement else {
59 problems.append("views.toml line \(table.line): expected [[view]]")
60 continue
61 }
62 guard let name = table.values["name"]?.string else {
63 problems.append("views.toml line \(table.line): a view needs a name")
64 continue
65 }
66 let match = table.values["match"]?.string
67 let mode: AgendaMode
68 switch table.values["type"]?.string ?? "agenda" {
69 case "agenda":
70 mode = .agenda(span: table.values["span"]?.integer, startOffset: table.values["start"]?.integer)
71 case "todo":
72 mode = .todo(keywords: table.values["keywords"]?.string)
73 case "match", "todo-match":
74 guard let match else {
75 problems.append("views.toml line \(table.line): \(name) needs a match")
76 continue
77 }
78 mode = .match(match, todoOnly: table.values["type"]?.string == "todo-match")
79 case let other:
80 problems.append("views.toml line \(table.line): unknown type \(other)")
81 continue
82 }
83 views.append(AgendaViewDefinition(name: name, mode: mode))
84 }
85 return (views, problems)
86 }
87}
Sources/OrgApp/DocumentSession.swift +45
@@ -271,6 +271,51 @@ public final class DocumentSession {
271 } 271 }
272 } 272 }
273 273
274 // MARK: - Commands from other views
275
276 /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does:
277 /// through the buffer when the file is open, leaving its caret alone; otherwise on the
278 /// file, written back at once through the save path. `expect` is the heading line the
279 /// caller saw at a position; if the text there changed, nothing runs.
280 public func perform(
281 _ command: any OrgCommand, path: String, at offset: Int, expect: (start: Int, line: String)?,
282 answers: [String: String] = [:], now: Date = Date()
283 ) -> CommandStep {
284 let stale = CommandStep.failed("The entry changed; the agenda has been refreshed.")
285 func matches(_ text: String) -> Bool {
286 guard let expect else { return true }
287 let ns = text as NSString
288 guard expect.start + (expect.line as NSString).length <= ns.length else { return false }
289 return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line
290 }
291 if path == self.path, let buffer {
292 guard matches(buffer.document.text) else { return stale }
293 do {
294 let options = self.options
295 let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) }
296 if case .commit = step { bufferChanged() }
297 return step
298 } catch {
299 return .failed("\(error)")
300 }
301 }
302 let url = URL(fileURLWithPath: path)
303 let name = url.lastPathComponent
304 do {
305 guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") }
306 var state = DocumentState(bytes: bytes, defaults: defaults)
307 guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") }
308 guard matches(state.text) else { return stale }
309 let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options)
310 if case .commit = step, state.isDirty, case .conflict = try saver.save(&state, to: url) {
311 return .failed("\(name) changed on disk; nothing was changed.")
312 }
313 return step
314 } catch {
315 return .failed("Can't change \(name): \(error.localizedDescription)")
316 }
317 }
318
274 // MARK: - Locations 319 // MARK: - Locations
275 320
276 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets 321 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
Sources/OrgCore/Agenda/Agenda.swift +4 −2
@@ -18,6 +18,8 @@ public struct AgendaItem: Sendable, Equatable {
18 public let path: String? 18 public let path: String?
19 /// Start of the entry's heading line. 19 /// Start of the entry's heading line.
20 public let headingOffset: Int? 20 public let headingOffset: Int?
21 /// The heading line as it was read, from its stars, to check the offsets still hold.
22 public let headingLine: String?
21 /// Where org's marker points: the timestamp, or the end of a range. 23 /// Where org's marker points: the timestamp, or the end of a range.
22 public let markerOffset: Int? 24 public let markerOffset: Int?
23 public let category: String 25 public let category: String
@@ -272,7 +274,7 @@ public enum Agenda {
272 txt = highlightTodo(txt, keywords: source.keywords) 274 txt = highlightTodo(txt, keywords: source.keywords)
273 let priority = Self.priority(prefix + txt, source.priorities) 275 let priority = Self.priority(prefix + txt, source.priorities)
274 return AgendaItem( 276 return AgendaItem(
275 kind: kind, path: source.path, headingOffset: heading.start, markerOffset: marker, category: category, 277 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category,
276 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), 278 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
277 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), 279 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
278 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) 280 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
@@ -388,7 +390,7 @@ public enum Agenda {
388 var txt = text 390 var txt = text
389 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) 391 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
390 return AgendaItem( 392 return AgendaItem(
391 kind: kind, path: nil, headingOffset: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, 393 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
392 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, 394 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0,
393 line: " " + pad("", 12) + pad(timed.time, 12) + txt 395 line: " " + pad("", 12) + pad(timed.time, 12) + txt
394 ) 396 )
Sources/OrgCore/Agenda/AgendaSource.swift +3 −1
@@ -23,6 +23,8 @@ public struct AgendaSource: Sendable {
23 /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`). 23 /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`).
24 let firstActive: String? 24 let firstActive: String?
25 let firstInactive: String? 25 let firstInactive: String?
26 /// The heading line without its newline.
27 let line: String
26 /// The line after the stars. 28 /// The line after the stars.
27 let head: String 29 let head: String
28 let todo: String? 30 let todo: String?
@@ -179,7 +181,7 @@ public struct AgendaSource: Sendable {
179 title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags, 181 title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags,
180 properties: properties, planning: planningValues, 182 properties: properties, planning: planningValues,
181 firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") }, 183 firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") },
182 head: head, todo: info.todo, isDone: info.isDone, 184 line: line.trimmingCharacters(in: .newlines), head: head, todo: info.todo, isDone: info.isDone,
183 tags: tags, category: category, scheduled: scheduled, deadline: deadline 185 tags: tags, category: category, scheduled: scheduled, deadline: deadline
184 )) 186 ))
185 } 187 }
Sources/OrgCore/Keymap/TOML.swift +5
@@ -18,6 +18,11 @@ public enum TOML {
18 if case .bool(let value) = self { return value } 18 if case .bool(let value) = self { return value }
19 return nil 19 return nil
20 } 20 }
21
22 public var integer: Int? {
23 if case .integer(let value) = self { return value }
24 return nil
25 }
21 } 26 }
22 27
23 public struct Table: Sendable, Equatable { 28 public struct Table: Sendable, Equatable {
Sources/Orgstar/AgendaView.swift +226 −42
@@ -2,20 +2,32 @@ import OrgApp
2import OrgCore 2import OrgCore
3import SwiftUI 3import SwiftUI
4 4
5/// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps 5/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
6/// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes. 6/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
7/// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or
8/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
7struct AgendaView: View { 9struct AgendaView: View {
8 let workspace: WorkspaceModel 10 let workspace: WorkspaceModel
9 let session: DocumentSession 11 let session: DocumentSession
10 let commands: AppCommands 12 let commands: AppCommands
11 @State private var agenda: AgendaModel 13 @State private var agenda: AgendaModel
14 @State private var views = AgendaViewLoader.builtIn
15 @State private var viewName = AgendaViewLoader.builtIn[0].name
16 @State private var matchText = ""
12 @State private var selection: String? 17 @State private var selection: String?
13 @State private var tick = 0 18 @State private var tick = 0
19 @State private var message: String?
20 @State private var prompt: PendingPrompt?
21 @State private var promptText = ""
22 @State private var prefix = false
14 @AppStorage("agendaSpan") private var span = 10 23 @AppStorage("agendaSpan") private var span = 10
15 @AppStorage("agendaStartOffset") private var startOffset = -3 24 @AppStorage("agendaStartOffset") private var startOffset = -3
16 @AppStorage("agendaSubfolders") private var includeSubfolders = false 25 @AppStorage("agendaSubfolders") private var includeSubfolders = false
17 @Environment(\.openWindow) private var openWindow 26 @Environment(\.openWindow) private var openWindow
18 27
28 static let match = "Match…"
29 static let todoMatch = "TODO Match…"
30
19 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { 31 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
20 self.workspace = workspace 32 self.workspace = workspace
21 self.session = session 33 self.session = session
@@ -23,7 +35,17 @@ struct AgendaView: View {
23 _agenda = State(initialValue: AgendaModel(workspace: workspace)) 35 _agenda = State(initialValue: AgendaModel(workspace: workspace))
24 } 36 }
25 37
38 struct PendingPrompt: Identifiable {
39 let id = UUID()
40 let prompt: Prompt
41 let command: any OrgCommand
42 let atMarker: Bool
43 let answers: [String: String]
44 let itemID: String
45 }
46
26 private struct RefreshKey: Hashable { 47 private struct RefreshKey: Hashable {
48 var mode: AgendaMode
27 var start: Int? 49 var start: Int?
28 var span: Int 50 var span: Int
29 var startOffset: Int 51 var startOffset: Int
@@ -37,25 +59,36 @@ struct AgendaView: View {
37 59
38 private var refreshKey: RefreshKey { 60 private var refreshKey: RefreshKey {
39 RefreshKey( 61 RefreshKey(
40 start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, 62 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
41 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, 63 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
42 path: session.path, tick: tick 64 path: session.path, tick: tick
43 ) 65 )
44 } 66 }
45 67
68 private var isAgenda: Bool {
69 if case .agenda = agenda.mode { return true }
70 return false
71 }
72
73 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
74
46 var body: some View { 75 var body: some View {
47 List(selection: $selection) { 76 List(selection: $selection) {
48 ForEach(agenda.days, id: \.day) { day in 77 if isAgenda {
49 Section { 78 ForEach(agenda.days, id: \.day) { day in
50 ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in 79 Section {
51 AgendaRow(item: item) 80 rows(day.items, prefix: "\(day.day)")
52 .tag(Self.id(day, index)) 81 } header: {
53 .selectionDisabled(item.path == nil) 82 Text(day.header)
83 .font(.headline)
84 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
54 } 85 }
86 }
87 } else {
88 Section {
89 rows(agenda.list, prefix: "list")
55 } header: { 90 } header: {
56 Text(day.header) 91 Text(listTitle).font(.headline)
57 .font(.headline)
58 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
59 } 92 }
60 } 93 }
61 } 94 }
@@ -63,29 +96,44 @@ struct AgendaView: View {
63 } primaryAction: { ids in 96 } primaryAction: { ids in
64 if let id = ids.first { jump(id) } 97 if let id = ids.first { jump(id) }
65 } 98 }
66 .onKeyPress(.return) { 99 .onKeyPress(phases: .down) { press in handle(press) }
67 guard let selection else { return .ignored } 100 .safeAreaInset(edge: .bottom, spacing: 0) {
68 jump(selection) 101 if let message {
69 return .handled 102 Text(message)
70 } 103 .font(.callout)
71 .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in 104 .frame(maxWidth: .infinity, alignment: .leading)
72 switch press.characters { 105 .padding(.horizontal, 12)
73 case "f": agenda.shift(forward: true) 106 .padding(.vertical, 6)
74 case "b": agenda.shift(forward: false) 107 .background(.bar)
75 case ".": agenda.showToday()
76 default: tick += 1
77 } 108 }
78 return .handled
79 } 109 }
80 .toolbar { 110 .toolbar {
111 ToolbarItemGroup(placement: .navigation) {
112 Menu {
113 ForEach(views) { view in Button(view.name) { viewName = view.name } }
114 Divider()
115 Button(Self.match) { viewName = Self.match }
116 Button(Self.todoMatch) { viewName = Self.todoMatch }
117 } label: {
118 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
119 }
120 .help("What the agenda shows")
121 if isMatch {
122 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
123 .frame(minWidth: 220)
124 .onSubmit { applyView() }
125 }
126 }
81 ToolbarItemGroup { 127 ToolbarItemGroup {
82 if agenda.isComputing { ProgressView().controlSize(.small) } 128 if agenda.isComputing { ProgressView().controlSize(.small) }
83 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } 129 if isAgenda {
84 .help("Previous \(span) days (b)") 130 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
85 Button("Today") { agenda.showToday() } 131 .help("Previous days (b)")
86 .help("Back to today (.)") 132 Button("Today") { agenda.showToday() }
87 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } 133 .help("Back to today (.)")
88 .help("Next \(span) days (f)") 134 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
135 .help("Next days (f)")
136 }
89 } 137 }
90 } 138 }
91 .overlay { 139 .overlay {
@@ -93,7 +141,17 @@ struct AgendaView: View {
93 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) 141 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
94 } 142 }
95 } 143 }
96 .navigationTitle("Agenda") 144 .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })) {
145 TextField("", text: $promptText)
146 Button("OK") { answerPrompt() }
147 Button("Cancel", role: .cancel) { prompt = nil }
148 } message: {
149 if let choices = prompt?.prompt.choices, !choices.isEmpty {
150 Text(choices.prefix(12).joined(separator: " "))
151 }
152 }
153 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
154 .onChange(of: viewName) { applyView() }
97 .task(id: refreshKey) { 155 .task(id: refreshKey) {
98 agenda.span = span 156 agenda.span = span
99 agenda.startOffset = startOffset 157 agenda.startOffset = startOffset
@@ -102,6 +160,7 @@ struct AgendaView: View {
102 await agenda.refresh(open: open) 160 await agenda.refresh(open: open)
103 } 161 }
104 .task { 162 .task {
163 loadViews()
105 // The time grid's "now" line and the day itself move on. 164 // The time grid's "now" line and the day itself move on.
106 while !Task.isCancelled { 165 while !Task.isCancelled {
107 try? await Task.sleep(for: .seconds(60)) 166 try? await Task.sleep(for: .seconds(60))
@@ -110,16 +169,135 @@ struct AgendaView: View {
110 } 169 }
111 } 170 }
112 171
113 private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" } 172 @ViewBuilder
173 private func rows(_ items: [AgendaItem], prefix: String) -> some View {
174 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
175 AgendaRow(item: item)
176 .tag("\(prefix):\(index)")
177 .selectionDisabled(item.path == nil)
178 }
179 }
180
181 private var listTitle: String {
182 switch agenda.mode {
183 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
184 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
185 case .agenda: ""
186 }
187 }
188
189 private func loadViews() {
190 let loaded = AgendaViewLoader.load()
191 views = loaded.views
192 message = loaded.problems.first
193 }
194
195 private func applyView() {
196 selection = nil
197 agenda.start = nil
198 switch viewName {
199 case Self.match, Self.todoMatch:
200 let match = matchText.trimmingCharacters(in: .whitespaces)
201 guard !match.isEmpty else { return }
202 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
203 default:
204 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
205 }
206 }
207
208 // MARK: - Items
209
210 private func item(_ id: String) -> AgendaItem? {
211 let parts = id.split(separator: ":")
212 guard parts.count == 2, let index = Int(parts[1]) else { return nil }
213 let items = parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []
214 return index < items.count ? items[index] : nil
215 }
114 216
115 private func jump(_ id: String) { 217 private func jump(_ id: String) {
116 let parts = id.split(separator: ":").compactMap { Int($0) } 218 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
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) 219 commands.jump(path: path, offset: offset)
121 openWindow(id: "main") 220 openWindow(id: "main")
122 } 221 }
222
223 // MARK: - Commands
224
225 private func handle(_ press: KeyPress) -> KeyPress.Result {
226 let control = press.modifiers.contains(.control)
227 if prefix {
228 prefix = false
229 switch (control, press.characters) {
230 case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false))
231 case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true))
232 case (true, "\u{11}"), (true, "q"): run(SetTags())
233 case (true, "\u{14}"), (true, "t"): run(TodoCycle())
234 default: message = "C-c \(press.characters) is undefined"
235 }
236 return .handled
237 }
238 if control, press.characters == "c" || press.characters == "\u{03}" {
239 prefix = true
240 message = "C-c-"
241 return .handled
242 }
243 if press.key == .return, let selection {
244 jump(selection)
245 return .handled
246 }
247 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
248 run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
249 return .handled
250 }
251 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
252 switch press.characters {
253 case "f" where isAgenda: agenda.shift(forward: true)
254 case "b" where isAgenda: agenda.shift(forward: false)
255 case "." where isAgenda: agenda.showToday()
256 case "g", "r":
257 loadViews()
258 tick += 1
259 case "t": run(TodoCycle())
260 case "+": run(PriorityUp())
261 case "-": run(PriorityDown())
262 case ":": run(SetTags())
263 default: return .ignored
264 }
265 return .handled
266 }
267
268 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
269 /// timestamp the entry is listed for.
270 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
271 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
272 message = "No entry selected"
273 return
274 }
275 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
276 message = "Only dated entries can move"
277 return
278 }
279 let offset = atMarker ? (item.markerOffset ?? start) : start
280 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
281 switch step {
282 case .prompt(let request):
283 promptText = request.initial
284 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
285 case .failed(let text):
286 message = text
287 tick += 1
288 case .commit:
289 message = nil
290 tick += 1
291 }
292 }
293
294 private func answerPrompt() {
295 guard let pending = prompt else { return }
296 prompt = nil
297 var answers = pending.answers
298 answers[pending.prompt.key] = promptText
299 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
300 }
123} 301}
124 302
125struct AgendaRow: View { 303struct AgendaRow: View {
@@ -129,7 +307,7 @@ struct AgendaRow: View {
129 switch item.kind { 307 switch item.kind {
130 case .timeGrid: 308 case .timeGrid:
131 HStack(spacing: 8) { 309 HStack(spacing: 8) {
132 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) 310 Text(Self.trim(item.time))
133 .monospacedDigit() 311 .monospacedDigit()
134 .frame(width: 140, alignment: .trailing) 312 .frame(width: 140, alignment: .trailing)
135 VStack { Divider() } 313 VStack { Divider() }
@@ -138,7 +316,7 @@ struct AgendaRow: View {
138 .foregroundStyle(.tertiary) 316 .foregroundStyle(.tertiary)
139 case .currentTime: 317 case .currentTime:
140 HStack(spacing: 8) { 318 HStack(spacing: 8) {
141 Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) 319 Text("now " + Self.trim(item.time))
142 .monospacedDigit() 320 .monospacedDigit()
143 .frame(width: 140, alignment: .trailing) 321 .frame(width: 140, alignment: .trailing)
144 Rectangle().fill(Color.accentColor).frame(height: 1) 322 Rectangle().fill(Color.accentColor).frame(height: 1)
@@ -151,9 +329,11 @@ struct AgendaRow: View {
151 .lineLimit(1) 329 .lineLimit(1)
152 .foregroundStyle(.secondary) 330 .foregroundStyle(.secondary)
153 .frame(width: 80, alignment: .leading) 331 .frame(width: 80, alignment: .leading)
154 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) 332 if item.kind != .todo, item.kind != .tagsMatch {
155 .monospacedDigit() 333 Text(Self.trim(item.time))
156 .frame(width: 80, alignment: .leading) 334 .monospacedDigit()
335 .frame(width: 80, alignment: .leading)
336 }
157 if !item.extra.isEmpty { 337 if !item.extra.isEmpty {
158 Text(item.extra.trimmingCharacters(in: .whitespaces)) 338 Text(item.extra.trimmingCharacters(in: .whitespaces))
159 .monospacedDigit() 339 .monospacedDigit()
@@ -171,6 +351,10 @@ struct AgendaRow: View {
171 } 351 }
172 } 352 }
173 353
354 static func trim(_ time: String) -> String {
355 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
356 }
357
174 private var leaderColor: Color { 358 private var leaderColor: Color {
175 switch item.kind { 359 switch item.kind {
176 case .deadline: .red 360 case .deadline: .red
@@ -182,8 +366,8 @@ struct AgendaRow: View {
182 366
183 /// The TODO keyword colored as in the editor. 367 /// The TODO keyword colored as in the editor.
184 private var title: Text { 368 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) 369 let text = item.text.trimmingCharacters(in: .whitespaces)
370 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
187 let rest = String(text.dropFirst(todo.count)) 371 let rest = String(text.dropFirst(todo.count))
188 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary) 372 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
189 } 373 }
Tests/OrgAppTests/AppTests.swift +67
@@ -306,3 +306,70 @@ struct AgendaModelTests {
306 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12)) 306 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12))
307 } 307 }
308} 308}
309
310@MainActor
311struct AgendaCommandTests {
312 @Test func loadsViews() throws {
313 let folder = try Folder()
314 let file = try folder.write("views.toml", """
315 [[view]]
316 name = "Work"
317 type = "match"
318 match = "+work"
319 [[view]]
320 name = "Week"
321 span = 7
322 start = -1
323 [[view]]
324 name = "Waiting"
325 type = "todo"
326 keywords = "WAIT|HOLD"
327 [[view]]
328 name = "Broken"
329 type = "match"
330 """)
331 let loaded = AgendaViewLoader.load(userFile: file)
332 #expect(loaded.views.map(\.name) == ["Agenda", "TODO List", "Work", "Week", "Waiting"])
333 #expect(loaded.views[2].mode == .match("+work", todoOnly: false))
334 #expect(loaded.views[3].mode == .agenda(span: 7, startOffset: -1))
335 #expect(loaded.views[4].mode == .todo(keywords: "WAIT|HOLD"))
336 #expect(loaded.problems == ["views.toml line 13: Broken needs a match"])
337 }
338
339 @Test func todoListMode() async throws {
340 let notes = try Folder()
341 try notes.write("a.org", "* TODO One :work:\n* DONE Two\n* TODO Three\n")
342 let model = try WorkspaceModel(directory: nil)
343 try await model.addRoot(notes.url)
344 let agenda = AgendaModel(workspace: model)
345 agenda.mode = .todo(keywords: nil)
346 await agenda.refresh(open: nil)
347 #expect(agenda.list.map(\.text) == ["TODO One", "TODO Three"])
348 agenda.mode = .match("work", todoOnly: false)
349 await agenda.refresh(open: nil)
350 #expect(agenda.list.map(\.text) == ["TODO One"])
351 }
352
353 @Test func runsCommandsOnClosedAndOpenFiles() throws {
354 let notes = try Folder()
355 let a = try notes.write("a.org", "* TODO One\n* TODO Two\n")
356 let b = try notes.write("b.org", "* TODO Three\n")
357 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
358 session.open(a.path)
359
360 // A closed file is changed on disk.
361 let closed = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three"))
362 guard case .commit = closed else { Issue.record("\(closed)"); return }
363 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
364
365 // The open file changes in its buffer.
366 let open = session.perform(TodoCycle(), path: a.path, at: 11, expect: (11, "* TODO Two"))
367 guard case .commit = open else { Issue.record("\(open)"); return }
368 #expect(session.document?.text == "* TODO One\n* DONE Two\n")
369
370 // A heading that moved is refused.
371 let stale = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three"))
372 guard case .failed = stale else { Issue.record("\(stale)"); return }
373 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
374 }
375}
docs/plans/2026-10-05-agenda-search.md +6 −6
@@ -19,7 +19,7 @@
19## Defaults chosen (user may change) 19## Defaults chosen (user may change)
20 20
21- Views: Agenda (`a`), TODO list (`t`), Match (`m`) and TODO match (`M`) are built in; `views.toml` adds named views. 21- Views: Agenda (`a`), TODO list (`t`), Match (`m`) and TODO match (`M`) are built in; `views.toml` adds named views.
22- Agenda keys follow Emacs: `t` TODO cycle, `+`/`-` priority, `,` priority prompt, `:` tags, `C-c C-s`/`C-c C-d` (⌘S-free: `s`/`d` too), `S-→`/`S-←` date shift. 22- Agenda keys follow Emacs: `t` TODO cycle, `+`/`-` priority, `,` priority prompt, `:` tags, `C-c C-s`/`C-c C-d`, `C-c C-q`, `S-→`/`S-←` date shift.
23 23
24--- 24---
25 25
@@ -31,11 +31,11 @@
31 31
32### Task 2: Views in the app 32### Task 2: Views in the app
33 33
34- [ ] `AgendaModel.mode` (agenda, TODO list, match); view picker and match field in the agenda window; `views.toml` loader with errors in the echo area. 34- [x] `AgendaModel.mode` (agenda, TODO list, match); view picker and match field in the agenda window; `views.toml` loader with errors in the echo area.
35- [ ] Commit "Agenda views". 35- [x] Commit "Agenda views".
36 36
37### Task 3: Agenda commands 37### Task 3: Agenda commands
38 38
39- [ ] `AgendaEditor`: run a command at a file offset, through the session or through `Saver`. 39- [x] `DocumentSession.perform(_:path:at:expect:)`: run a command at a file offset, through the buffer or through `Saver`.
40- [ ] Keys in the agenda window; prompts for dates, tags and priority; the agenda refreshes after each. 40- [x] Keys in the agenda window; prompts for dates, tags and priority; the agenda refreshes after each.
41- [ ] Commit "Agenda commands". 41- [x] Commit "Agenda commands".