Commit 99990e7b68
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +67 −19
| @@ -356,16 +356,43 @@ public final class DocumentSession { | ||
| 356 | 356 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options) |
| 357 | 357 | } |
| 358 | 358 | |
| 359 | /// Moves the subtree at the caret under `target`. For another file, that file is written | |
| 360 | /// first, through the save path; the subtree leaves this buffer only once it has. Returns a | |
| 361 | /// message for the echo area. | |
| 359 | /// The file and context a subtree command works on: `from` (a file and a heading in it, | |
| 360 | /// as the agenda gives them), or the current buffer at its caret. | |
| 361 | private func source(_ from: (path: String, offset: Int)?, now: Date) -> (path: String, context: EditContext)? { | |
| 362 | guard let from else { | |
| 363 | guard let path, let context = editContext(now: now) else { return nil } | |
| 364 | return (path, context) | |
| 365 | } | |
| 366 | let state: DocumentState | |
| 367 | if let open = entry(from.path) { | |
| 368 | state = open.buffer.document | |
| 369 | } else { | |
| 370 | guard let bytes = try? fileSystem.read(URL(fileURLWithPath: from.path)) else { return nil } | |
| 371 | state = DocumentState(bytes: bytes, defaults: defaults) | |
| 372 | } | |
| 373 | guard state.isEditable else { return nil } | |
| 374 | let context = EditContext(revision: state.revision, text: state.text, tree: state.tree, selection: [from.offset..<from.offset], now: now, options: options) | |
| 375 | return (from.path, context) | |
| 376 | } | |
| 377 | ||
| 378 | /// Applies an edit computed from `source(_:now:)` to that file, open or not. | |
| 379 | private func applySource(_ edit: EditResult, path: String, now: Date) -> String? { | |
| 380 | if case .failed(let message) = perform(PrecomputedEdit(edit), path: path, at: 0, expect: nil, now: now) { return message } | |
| 381 | return nil | |
| 382 | } | |
| 383 | ||
| 384 | /// Moves the subtree at the caret, or at `from`, under `target`. For another file, that | |
| 385 | /// file is written first, through the save path; the subtree leaves its file only once it | |
| 386 | /// has. Returns a message for the echo area. | |
| 362 | 387 | @discardableResult |
| 363 | public func refile(to target: RefileTarget, now: Date = Date()) -> String? { | |
| 364 | guard let buffer, let path, let context = editContext(now: now) else { return nil } | |
| 388 | public func refile(to target: RefileTarget, from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? { | |
| 389 | guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." } | |
| 365 | 390 | if target.path == path { |
| 366 | let step = buffer.perform(RefileWithinFile(), now: now, answers: ["refile": target.start.map(String.init) ?? ""]) | |
| 367 | if case .failed(let message) = step { return message } | |
| 368 | bufferChanged() | |
| 391 | let answers = ["refile": target.start.map(String.init) ?? ""] | |
| 392 | let step = from == nil ? buffer?.perform(RefileWithinFile(), now: now, answers: answers) | |
| 393 | : perform(RefileWithinFile(), path: path, at: from!.offset, expect: nil, answers: answers, now: now) | |
| 394 | if case .failed(let message)? = step { return message } | |
| 395 | if from == nil { bufferChanged() } | |
| 369 | 396 | return "Refiled to \(target.label)" |
| 370 | 397 | } |
| 371 | 398 | let url = URL(fileURLWithPath: target.path) |
| @@ -379,8 +406,7 @@ public final class DocumentSession { | ||
| 379 | 406 | let edit = EditResult(baseRevision: state.revision, edits: [result.target]) |
| 380 | 407 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } |
| 381 | 408 | changed(open) |
| 382 | if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message } | |
| 383 | bufferChanged() | |
| 409 | if let message = applySource(result.source, path: path, now: now) { return message } | |
| 384 | 410 | return "Refiled to \(target.label)" |
| 385 | 411 | } |
| 386 | 412 | do { |
| @@ -391,18 +417,18 @@ public final class DocumentSession { | ||
| 391 | 417 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } |
| 392 | 418 | try state.apply([result.target], baseRevision: state.revision) |
| 393 | 419 | if case .conflict = try saver.save(&state, to: url) { return "\(targetName) changed on disk; nothing was refiled." } |
| 394 | if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message } | |
| 395 | bufferChanged() | |
| 420 | if let message = applySource(result.source, path: path, now: now) { return message } | |
| 396 | 421 | return "Refiled to \(target.label)" |
| 397 | 422 | } catch { |
| 398 | 423 | return "Can't refile: \(error.localizedDescription)" |
| 399 | 424 | } |
| 400 | 425 | } |
| 401 | 426 | |
| 402 | /// Archives the subtree at the caret where `org-archive-subtree` would. Returns a message. | |
| 427 | /// Archives the subtree at the caret, or at `from`, where `org-archive-subtree` would. | |
| 428 | /// Returns a message. | |
| 403 | 429 | @discardableResult |
| 404 | public func archive(now: Date = Date()) -> String? { | |
| 405 | guard let buffer, let path, let context = editContext(now: now) else { return nil } | |
| 430 | public func archive(from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? { | |
| 431 | guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." } | |
| 406 | 432 | let destination: Archive.Destination |
| 407 | 433 | switch Archive.destination(context, sourcePath: path) { |
| 408 | 434 | case .success(let value): destination = value |
| @@ -423,8 +449,7 @@ public final class DocumentSession { | ||
| 423 | 449 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } |
| 424 | 450 | changed(open) |
| 425 | 451 | } |
| 426 | if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message } | |
| 427 | bufferChanged() | |
| 452 | if let message = applySource(edits.source, path: path, now: now) { return message } | |
| 428 | 453 | return "Archived to \(name)" |
| 429 | 454 | } |
| 430 | 455 | do { |
| @@ -441,8 +466,7 @@ public final class DocumentSession { | ||
| 441 | 466 | try state.apply([archiveEdit], baseRevision: state.revision) |
| 442 | 467 | if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." } |
| 443 | 468 | } |
| 444 | if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message } | |
| 445 | bufferChanged() | |
| 469 | if let message = applySource(edits.source, path: path, now: now) { return message } | |
| 446 | 470 | return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)" |
| 447 | 471 | } catch { |
| 448 | 472 | return "Can't archive: \(error.localizedDescription)" |
| @@ -451,6 +475,30 @@ public final class DocumentSession { | ||
| 451 | 475 | |
| 452 | 476 | // MARK: - Commands from other views |
| 453 | 477 | |
| 478 | /// Where the heading line `line` is now in `path`: at `offset` if it still reads so, else | |
| 479 | /// the copy of the line nearest `offset`. For acting on agenda entries after other edits | |
| 480 | /// moved them. | |
| 481 | public func locateHeading(path: String, line: String, near offset: Int) -> Int? { | |
| 482 | guard let text = entry(path)?.buffer.document.text ?? (try? fileSystem.read(URL(fileURLWithPath: path))).flatMap({ $0.map { SourceText(bytes: $0).text } }) else { return nil } | |
| 483 | let ns = text as NSString | |
| 484 | let length = (line as NSString).length | |
| 485 | if offset + length <= ns.length, ns.substring(with: NSRange(location: offset, length: length)) == line, | |
| 486 | offset == 0 || ns.character(at: offset - 1) == 10 { | |
| 487 | return offset | |
| 488 | } | |
| 489 | var best: Int? | |
| 490 | var search = NSRange(location: 0, length: ns.length) | |
| 491 | while true { | |
| 492 | let found = ns.range(of: line, options: [], range: search) | |
| 493 | guard found.location != NSNotFound else { break } | |
| 494 | let atLineStart = found.location == 0 || ns.character(at: found.location - 1) == 10 | |
| 495 | let lineEnd = NSMaxRange(found) == ns.length || ns.character(at: NSMaxRange(found)) == 10 | |
| 496 | if atLineStart, lineEnd, best.map({ abs($0 - offset) > abs(found.location - offset) }) ?? true { best = found.location } | |
| 497 | search = NSRange(location: NSMaxRange(found), length: ns.length - NSMaxRange(found)) | |
| 498 | } | |
| 499 | return best | |
| 500 | } | |
| 501 | ||
| 454 | 502 | /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does: |
| 455 | 503 | /// through the buffer when the file is open, leaving its caret alone; otherwise on the |
| 456 | 504 | /// file, written back at once through the save path. `expect` is the heading line the |
Sources/OrgCore/Commands/TagCommands.swift +30 −1
| @@ -54,7 +54,12 @@ public struct SetTags: OrgCommand { | ||
| 54 | 54 | } |
| 55 | 55 | tags = Self.tags(from: answer) |
| 56 | 56 | } |
| 57 | return commitBuffer(context) { buffer in | |
| 57 | return Self.set(tags, in: context) | |
| 58 | } | |
| 59 | ||
| 60 | /// `org-set-tags`: the entry's tags become `tags`, realigned. | |
| 61 | static func set(_ tags: [String], in context: EditContext) -> CommandStep { | |
| 62 | commitBuffer(context) { buffer in | |
| 58 | 63 | try buffer.saveExcursion { |
| 59 | 64 | try buffer.backToHeading() |
| 60 | 65 | try buffer.saveExcursion { |
| @@ -79,3 +84,27 @@ public struct SetTags: OrgCommand { | ||
| 79 | 84 | } |
| 80 | 85 | } |
| 81 | 86 | } |
| 87 | ||
| 88 | /// `org-toggle-tag` with `on` or `off`: adds or removes one tag, as the agenda's bulk tag | |
| 89 | /// actions do. | |
| 90 | public struct ToggleTag: OrgCommand { | |
| 91 | public let tag: String | |
| 92 | public let on: Bool | |
| 93 | public init(tag: String, on: Bool) { | |
| 94 | self.tag = tag | |
| 95 | self.on = on | |
| 96 | } | |
| 97 | public var id: String { "org.tags.toggle" } | |
| 98 | public var title: String { on ? "Add Tag" : "Remove Tag" } | |
| 99 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } | |
| 100 | public func run(in context: EditContext) -> CommandStep { | |
| 101 | guard let heading = entryHeading(at: context.caret, in: context.tree) else { return .failed("Before first headline") } | |
| 102 | var tags = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)).split(separator: ":").map(String.init) } ?? [] | |
| 103 | if on { | |
| 104 | if !tags.contains(tag) { tags.append(tag) } | |
| 105 | } else { | |
| 106 | tags.removeAll { $0 == tag } | |
| 107 | } | |
| 108 | return SetTags.set(tags, in: context) | |
| 109 | } | |
| 110 | } | |
Sources/Orgstar/AgendaView.swift +219 −16
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import OrgApp |
| 2 | import OrgDocument | |
| 2 | 3 | import OrgCore |
| 3 | 4 | import OrgPresentation |
| 4 | 5 | import SwiftUI |
| @@ -7,13 +8,15 @@ import SwiftUI | ||
| 7 | 8 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: |
| 8 | 9 | /// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters |
| 9 | 10 | /// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort, |
| 10 | /// | clears filters, t cycles the TODO state, +/- change the priority, : or | |
| 11 | /// | clears filters, I/O/X clock in, out and cancel, C-c C-w refiles, $ archives, m/u/U mark | |
| 12 | /// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority, : or | |
| 11 | 13 | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. |
| 12 | 14 | struct AgendaView: View { |
| 13 | 15 | let workspace: WorkspaceModel |
| 14 | 16 | let session: DocumentSession |
| 15 | 17 | let commands: AppCommands |
| 16 | 18 | let scheduler: ReminderScheduler? |
| 19 | let clock: ClockModel | |
| 17 | 20 | @State private var agenda: AgendaModel |
| 18 | 21 | @State private var views = AgendaViewLoader.builtIn |
| 19 | 22 | @State private var viewName = AgendaViewLoader.builtIn[0].name |
| @@ -23,12 +26,21 @@ struct AgendaView: View { | ||
| 23 | 26 | @State private var message: String? |
| 24 | 27 | @State private var prompt: PendingPrompt? |
| 25 | 28 | @State private var promptText = "" |
| 26 | @State private var prefix = false | |
| 29 | @State private var prefix: String? | |
| 30 | /// Entries marked for a bulk action. | |
| 31 | @State private var marked: [EntryRef] = [] | |
| 27 | 32 | @State private var logMode = false |
| 28 | 33 | @State private var filter = AgendaFilter() |
| 29 | /// A filter question: its title, the text it starts with, and how the answer applies. | |
| 34 | /// A filter question: its title and which filter it sets. | |
| 30 | 35 | @State private var filterPrompt: (title: String, kind: Character)? |
| 31 | 36 | @State private var filterText = "" |
| 37 | /// A one-line question for an agenda action, and what to do with the answer. | |
| 38 | struct TextPrompt { | |
| 39 | let title: String | |
| 40 | let apply: (String) -> Void | |
| 41 | } | |
| 42 | @State private var textPrompt: TextPrompt? | |
| 43 | @State private var textAnswer = "" | |
| 32 | 44 | @AppStorage("agendaSpan") private var span = 10 |
| 33 | 45 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 34 | 46 | @AppStorage("agendaSubfolders") private var includeSubfolders = false |
| @@ -40,14 +52,29 @@ struct AgendaView: View { | ||
| 40 | 52 | static let match = "Match…" |
| 41 | 53 | static let todoMatch = "TODO Match…" |
| 42 | 54 | |
| 43 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) { | |
| 55 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) { | |
| 44 | 56 | self.workspace = workspace |
| 45 | 57 | self.session = session |
| 46 | 58 | self.commands = commands |
| 47 | 59 | self.scheduler = scheduler |
| 60 | self.clock = clock | |
| 48 | 61 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) |
| 49 | 62 | } |
| 50 | 63 | |
| 64 | /// An agenda entry by its file, heading line and where that line was. | |
| 65 | struct EntryRef: Hashable { | |
| 66 | let path: String | |
| 67 | let line: String | |
| 68 | let offset: Int | |
| 69 | ||
| 70 | init?(_ item: AgendaItem) { | |
| 71 | guard let path = item.path, let line = item.headingLine, let offset = item.headingOffset else { return nil } | |
| 72 | self.path = path | |
| 73 | self.line = line | |
| 74 | self.offset = offset | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 51 | 78 | struct PendingPrompt: Identifiable { |
| 52 | 79 | let id = UUID() |
| 53 | 80 | let prompt: Prompt |
| @@ -197,6 +224,21 @@ struct AgendaView: View { | ||
| 197 | 224 | Text(shown.tags.sorted().prefix(20).joined(separator: " ")) |
| 198 | 225 | } |
| 199 | 226 | } |
| 227 | .alert(textPrompt?.title ?? "", isPresented: Binding(get: { textPrompt != nil }, set: { if !$0 { textPrompt = nil } })) { | |
| 228 | TextField("", text: $textAnswer) | |
| 229 | Button("OK") { | |
| 230 | let pending = textPrompt | |
| 231 | let answer = textAnswer | |
| 232 | textPrompt = nil | |
| 233 | textAnswer = "" | |
| 234 | // A follow-up question opens after this alert closes. | |
| 235 | DispatchQueue.main.async { pending?.apply(answer) } | |
| 236 | } | |
| 237 | Button("Cancel", role: .cancel) { | |
| 238 | textPrompt = nil | |
| 239 | textAnswer = "" | |
| 240 | } | |
| 241 | } | |
| 200 | 242 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) |
| 201 | 243 | .onChange(of: viewName) { applyView() } |
| 202 | 244 | .task(id: refreshKey) { |
| @@ -221,7 +263,7 @@ struct AgendaView: View { | ||
| 221 | 263 | @ViewBuilder |
| 222 | 264 | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { |
| 223 | 265 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 224 | AgendaRow(item: item, day: day, customPrefix: customPrefix) | |
| 266 | AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false) | |
| 225 | 267 | .tag("\(prefix):\(index)") |
| 226 | 268 | .selectionDisabled(item.path == nil) |
| 227 | 269 | } |
| @@ -338,19 +380,36 @@ struct AgendaView: View { | ||
| 338 | 380 | |
| 339 | 381 | private func handle(_ press: KeyPress) -> KeyPress.Result { |
| 340 | 382 | let control = press.modifiers.contains(.control) |
| 341 | if prefix { | |
| 342 | prefix = false | |
| 343 | switch (control, press.characters) { | |
| 344 | case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false)) | |
| 345 | case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true)) | |
| 346 | case (true, "\u{11}"), (true, "q"): run(SetTags()) | |
| 347 | case (true, "\u{14}"), (true, "t"): run(TodoCycle()) | |
| 348 | default: message = "C-c \(press.characters) is undefined" | |
| 383 | // `C-x` and friends arrive as control characters or as letters with control. | |
| 384 | func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) } | |
| 385 | if let pending = prefix { | |
| 386 | prefix = nil | |
| 387 | if pending == "C-c C-x" { | |
| 388 | if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" } | |
| 389 | return .handled | |
| 390 | } | |
| 391 | if isControl("s", "\u{13}") { | |
| 392 | run(SetPlanning(deadline: false)) | |
| 393 | } else if isControl("d", "\u{04}") { | |
| 394 | run(SetPlanning(deadline: true)) | |
| 395 | } else if isControl("q", "\u{11}") { | |
| 396 | run(SetTags()) | |
| 397 | } else if isControl("t", "\u{14}") { | |
| 398 | run(TodoCycle()) | |
| 399 | } else if isControl("w", "\u{17}") { | |
| 400 | refile(marked: false) | |
| 401 | } else if isControl("x", "\u{18}") { | |
| 402 | prefix = "C-c C-x" | |
| 403 | message = "C-c C-x-" | |
| 404 | } else if press.characters == "$" { | |
| 405 | archive() | |
| 406 | } else { | |
| 407 | message = "C-c \(press.characters) is undefined" | |
| 349 | 408 | } |
| 350 | 409 | return .handled |
| 351 | 410 | } |
| 352 | if control, press.characters == "c" || press.characters == "\u{03}" { | |
| 353 | prefix = true | |
| 411 | if isControl("c", "\u{03}") { | |
| 412 | prefix = "C-c" | |
| 354 | 413 | message = "C-c-" |
| 355 | 414 | return .handled |
| 356 | 415 | } |
| @@ -378,6 +437,16 @@ struct AgendaView: View { | ||
| 378 | 437 | case "g", "r": |
| 379 | 438 | loadViews() |
| 380 | 439 | tick += 1 |
| 440 | case "I": clockIn() | |
| 441 | case "O": message = clock.clockOut(session) | |
| 442 | case "X": message = clock.clockOut(session, cancel: true) | |
| 443 | case "$": archive() | |
| 444 | case "m": mark(true) | |
| 445 | case "u": mark(false) | |
| 446 | case "U": | |
| 447 | marked = [] | |
| 448 | message = "No entries marked" | |
| 449 | case "B": askBulk() | |
| 381 | 450 | case "t": run(TodoCycle()) |
| 382 | 451 | case "+": run(PriorityUp()) |
| 383 | 452 | case "-": run(PriorityDown()) |
| @@ -387,6 +456,130 @@ struct AgendaView: View { | ||
| 387 | 456 | return .handled |
| 388 | 457 | } |
| 389 | 458 | |
| 459 | // MARK: - Clock, refile, archive, bulk | |
| 460 | ||
| 461 | private var selectedEntry: EntryRef? { selection.flatMap(item).flatMap(EntryRef.init) } | |
| 462 | ||
| 463 | private func clockIn() { | |
| 464 | guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else { | |
| 465 | message = "No entry selected" | |
| 466 | return | |
| 467 | } | |
| 468 | message = clock.clockIn(session, path: entry.path, at: offset) | |
| 469 | tick += 1 | |
| 470 | } | |
| 471 | ||
| 472 | private func archive() { | |
| 473 | guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else { | |
| 474 | message = "No entry selected" | |
| 475 | return | |
| 476 | } | |
| 477 | message = session.archive(from: (entry.path, offset)) | |
| 478 | tick += 1 | |
| 479 | } | |
| 480 | ||
| 481 | /// Refiles the selected entry, or with `marked` the marked ones, to a target asked for in | |
| 482 | /// the main window, where the list of targets is searchable. | |
| 483 | private func refile(marked bulk: Bool) { | |
| 484 | let entries = bulk ? marked : selectedEntry.map { [$0] } ?? [] | |
| 485 | guard !entries.isEmpty else { | |
| 486 | message = "No entry selected" | |
| 487 | return | |
| 488 | } | |
| 489 | let targets = workspace.refileTargets(openPath: session.path, document: session.document) | |
| 490 | commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in | |
| 491 | guard let answer, targets.contains(where: { $0.label == answer }) else { return } | |
| 492 | message = eachEntry(entries) { entry, offset in | |
| 493 | // Targets in the entry's own file from its text now, as earlier moves shift them. | |
| 494 | let document = session.openDocuments.first { $0.path == entry.path }?.document | |
| 495 | ?? FileManager.default.contents(atPath: entry.path).map { DocumentState(bytes: [UInt8]($0)) } | |
| 496 | guard let target = workspace.refileTargets(openPath: entry.path, document: document).first(where: { $0.label == answer }) else { | |
| 497 | return "Can't find \(answer) any more." | |
| 498 | } | |
| 499 | return session.refile(to: target, from: (entry.path, offset)) | |
| 500 | } | |
| 501 | if bulk { marked = [] } | |
| 502 | tick += 1 | |
| 503 | } | |
| 504 | openWindow(id: "main") | |
| 505 | } | |
| 506 | ||
| 507 | /// Runs `action` on each entry, finding it again first: later entries in a file first, so | |
| 508 | /// earlier ones keep their place. Returns the last message. | |
| 509 | private func eachEntry(_ entries: [EntryRef], _ action: (EntryRef, Int) -> String?) -> String? { | |
| 510 | var last: String? | |
| 511 | var failed = 0 | |
| 512 | for entry in entries.sorted(by: { ($0.path, $1.offset) < ($1.path, $0.offset) }) { | |
| 513 | guard let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else { | |
| 514 | failed += 1 | |
| 515 | continue | |
| 516 | } | |
| 517 | last = action(entry, offset) | |
| 518 | } | |
| 519 | return failed > 0 ? "\(failed) marked \(failed == 1 ? "entry" : "entries") changed and \(failed == 1 ? "was" : "were") skipped" : last | |
| 520 | } | |
| 521 | ||
| 522 | private func mark(_ on: Bool) { | |
| 523 | guard let entry = selectedEntry else { | |
| 524 | message = "No entry selected" | |
| 525 | return | |
| 526 | } | |
| 527 | if on { | |
| 528 | if !marked.contains(entry) { marked.append(entry) } | |
| 529 | } else { | |
| 530 | marked.removeAll { $0 == entry } | |
| 531 | } | |
| 532 | message = "\(marked.count) marked" | |
| 533 | } | |
| 534 | ||
| 535 | /// `B`: an action for every marked entry. | |
| 536 | private func askBulk() { | |
| 537 | guard !marked.isEmpty else { | |
| 538 | message = "No entries are marked" | |
| 539 | return | |
| 540 | } | |
| 541 | textPrompt = TextPrompt(title: "Bulk action on \(marked.count): [$] archive [r] refile [t] TODO state [+] tag [-] untag [s] schedule [d] deadline") { answer in | |
| 542 | switch answer.trimmingCharacters(in: .whitespaces) { | |
| 543 | case "$": bulk { session.archive(from: ($0.path, $1)) } | |
| 544 | case "r": refile(marked: true) | |
| 545 | case "t": | |
| 546 | textPrompt = TextPrompt(title: "TODO state (empty for none):") { keyword in | |
| 547 | let word = keyword.trimmingCharacters(in: .whitespaces) | |
| 548 | bulk { performAt($0, $1, SetTodo(keyword: word.isEmpty ? nil : word)) } | |
| 549 | } | |
| 550 | case "+", "-": | |
| 551 | let on = answer.trimmingCharacters(in: .whitespaces) == "+" | |
| 552 | textPrompt = TextPrompt(title: on ? "Tag to add:" : "Tag to remove:") { tag in | |
| 553 | let tag = tag.trimmingCharacters(in: .whitespaces) | |
| 554 | guard !tag.isEmpty else { return } | |
| 555 | bulk { performAt($0, $1, ToggleTag(tag: tag, on: on)) } | |
| 556 | } | |
| 557 | case "s", "d": | |
| 558 | let deadline = answer.trimmingCharacters(in: .whitespaces) == "d" | |
| 559 | textPrompt = TextPrompt(title: deadline ? "Deadline (+2d, fri, 2026-10-12):" : "Schedule (+2d, fri, 2026-10-12):") { date in | |
| 560 | bulk { performAt($0, $1, SetPlanning(deadline: deadline), answers: ["date": date]) } | |
| 561 | } | |
| 562 | default: | |
| 563 | message = "No bulk action \(answer)" | |
| 564 | } | |
| 565 | } | |
| 566 | } | |
| 567 | ||
| 568 | private func bulk(_ action: (EntryRef, Int) -> String?) { | |
| 569 | message = eachEntry(marked, action) ?? "Done with \(marked.count) entries" | |
| 570 | marked = [] | |
| 571 | tick += 1 | |
| 572 | } | |
| 573 | ||
| 574 | /// Runs `command` at the heading at `offset`, as `run` does for the selection. | |
| 575 | private func performAt(_ entry: EntryRef, _ offset: Int, _ command: any OrgCommand, answers: [String: String] = [:]) -> String? { | |
| 576 | switch session.perform(command, path: entry.path, at: offset, expect: (offset, entry.line), answers: answers) { | |
| 577 | case .failed(let text): text | |
| 578 | case .prompt: "\(command.title) needs an answer; run it on the entry itself" | |
| 579 | default: nil | |
| 580 | } | |
| 581 | } | |
| 582 | ||
| 390 | 583 | /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the |
| 391 | 584 | /// timestamp the entry is listed for. |
| 392 | 585 | private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) { |
| @@ -430,6 +623,8 @@ struct AgendaRow: View { | ||
| 430 | 623 | var day: Int? |
| 431 | 624 | /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns. |
| 432 | 625 | var customPrefix = false |
| 626 | /// Marked for a bulk action. | |
| 627 | var marked = false | |
| 433 | 628 | @Environment(\.orgTheme) private var theme |
| 434 | 629 | |
| 435 | 630 | var body: some View { |
| @@ -453,6 +648,15 @@ struct AgendaRow: View { | ||
| 453 | 648 | .font(.caption) |
| 454 | 649 | .foregroundStyle(theme.color("agenda-today") ?? .accentColor) |
| 455 | 650 | default: |
| 651 | HStack(spacing: 4) { | |
| 652 | if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) } | |
| 653 | entryRow | |
| 654 | } | |
| 655 | } | |
| 656 | } | |
| 657 | ||
| 658 | @ViewBuilder | |
| 659 | private var entryRow: some View { | |
| 456 | 660 | if customPrefix { |
| 457 | 661 | HStack(spacing: 0) { |
| 458 | 662 | Text(item.prefix) |
| @@ -495,7 +699,6 @@ struct AgendaRow: View { | ||
| 495 | 699 | } |
| 496 | 700 | } |
| 497 | 701 | } |
| 498 | } | |
| 499 | 702 | } |
| 500 | 703 | |
| 501 | 704 | static func trim(_ time: String) -> String { |
Sources/Orgstar/OrgstarApp.swift +1 −1
| @@ -250,7 +250,7 @@ struct OrgstarApp: App { | ||
| 250 | 250 | .defaultSize(width: 1280, height: 860) |
| 251 | 251 | Window("Agenda", id: "agenda") { |
| 252 | 252 | if case .success(let model) = workspace { |
| 253 | AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler) | |
| 253 | AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock) | |
| 254 | 254 | .environment(\.orgTheme, ConfigSync.shared.theme) |
| 255 | 255 | } |
| 256 | 256 | } |
Tests/OrgAppTests/AppTests.swift +41
| @@ -305,6 +305,47 @@ struct MoveSubtreeTests { | ||
| 305 | 305 | } |
| 306 | 306 | } |
| 307 | 307 | |
| 308 | @MainActor | |
| 309 | struct AgendaActionTests { | |
| 310 | /// From the agenda: entries in files that aren't open move and archive on disk, and the | |
| 311 | /// current buffer stays as it was. | |
| 312 | @Test func refileAndArchiveFromAnEntry() async throws { | |
| 313 | let notes = try Folder() | |
| 314 | let file = try notes.write("a.org", "* one\nbody\n* two\n* three\n") | |
| 315 | let other = try notes.write("b.org", "* inbox\n") | |
| 316 | let shown = try notes.write("c.org", "* shown\n") | |
| 317 | let workspace = try WorkspaceModel(directory: nil) | |
| 318 | try await workspace.addRoot(notes.url) | |
| 319 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 320 | session.saveMode = .explicit | |
| 321 | session.open(shown.path) | |
| 322 | ||
| 323 | let inbox = try #require(workspace.refileTargets(openPath: nil, document: nil).first { $0.label == "b.org/inbox" }) | |
| 324 | #expect(session.refile(to: inbox, from: (file.path, 0)) == "Refiled to b.org/inbox") | |
| 325 | #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n** one\nbody\n") | |
| 326 | #expect(try String(contentsOf: file, encoding: .utf8) == "* two\n* three\n") | |
| 327 | ||
| 328 | let text = try String(contentsOf: file, encoding: .utf8) | |
| 329 | let two = try #require(workspace.refileTargets(openPath: file.path, document: DocumentState(bytes: [UInt8](text.utf8))).first { $0.label == "a.org/two" }) | |
| 330 | #expect(session.refile(to: two, from: (file.path, 6)) == "Refiled to a.org/two") | |
| 331 | #expect(try String(contentsOf: file, encoding: .utf8) == "* two\n** three\n") | |
| 332 | ||
| 333 | #expect(session.archive(from: (file.path, 0), now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive") | |
| 334 | #expect(try String(contentsOf: file, encoding: .utf8) == "") | |
| 335 | #expect(session.path == shown.path && session.document?.text == "* shown\n") | |
| 336 | } | |
| 337 | ||
| 338 | @Test func locatesMovedHeadings() throws { | |
| 339 | let notes = try Folder() | |
| 340 | let file = try notes.write("a.org", "* new\n* task\n* other\n* task\n") | |
| 341 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 342 | #expect(session.locateHeading(path: file.path, line: "* task", near: 0) == 6) | |
| 343 | #expect(session.locateHeading(path: file.path, line: "* task", near: 20) == 21) | |
| 344 | #expect(session.locateHeading(path: file.path, line: "* other", near: 14) == 13) | |
| 345 | #expect(session.locateHeading(path: file.path, line: "* gone", near: 0) == nil) | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 308 | 349 | @MainActor |
| 309 | 350 | struct MoveIntoOpenBufferTests { |
| 310 | 351 | /// A target open with unsaved edits above its heading takes the subtree in its buffer, |
Tests/OrgCoreTests/TagCommandTests.swift +18
| @@ -47,3 +47,21 @@ struct TagCommandTests { | ||
| 47 | 47 | #expect(mismatches == 0) |
| 48 | 48 | } |
| 49 | 49 | } |
| 50 | ||
| 51 | struct ToggleTagTests { | |
| 52 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 53 | func togglesLikeOrgToggleTag() throws { | |
| 54 | let texts = ["* TODO Task\n", "* Task :a:b:\nbody\n", "* Task :x:\n", "* A longer heading title here :a:\n"] | |
| 55 | var cases: [EmacsOracle.Case] = [] | |
| 56 | var ours: [String] = [] | |
| 57 | for text in texts { | |
| 58 | for (tag, on) in [("x", true), ("a", false), ("x", false), ("new", true)] { | |
| 59 | cases.append(EmacsOracle.Case(text: text, point: 3, form: "(org-toggle-tag \"\(tag)\" '\(on ? "on" : "off"))")) | |
| 60 | ours.append(runCommand(ToggleTag(tag: tag, on: on), text, caret: 2).text) | |
| 61 | } | |
| 62 | } | |
| 63 | for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() { | |
| 64 | #expect(mine == theirs.text, "\(cases[i].form) on \(cases[i].text.debugDescription)") | |
| 65 | } | |
| 66 | } | |
| 67 | } | |