Commit d5d6891199
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +29 −3
| @@ -10,6 +10,9 @@ import OrgIndex | |||
| 10 | @Observable | 10 | @Observable |
| 11 | public final class AgendaModel { | 11 | public 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | /// What the agenda window shows. | ||
| 5 | public 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`. | ||
| 15 | public 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 | ||
| 38 | public 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 | |||
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import SwiftUI | 3 | import 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. | ||
| 7 | struct AgendaView: View { | 9 | struct 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 | ||
| 125 | struct AgendaRow: View { | 303 | struct 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 | ||
| 311 | struct 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". |