Agenda clock, refile, archive and bulk actions !75

merged merged by cmc on 2026-10-06 03:00 UTC · krz/orgstar:agenda-actions into main

6 files changed, +376 −37

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +67 −19
@@ -356,16 +356,43 @@ public final class DocumentSession {
356356 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options)
357357 }
358358
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.
362387 @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." }
365390 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() }
369396 return "Refiled to \(target.label)"
370397 }
371398 let url = URL(fileURLWithPath: target.path)
@@ -379,8 +406,7 @@ public final class DocumentSession {
379406 let edit = EditResult(baseRevision: state.revision, edits: [result.target])
380407 if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
381408 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 }
384410 return "Refiled to \(target.label)"
385411 }
386412 do {
@@ -391,18 +417,18 @@ public final class DocumentSession {
391417 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
392418 try state.apply([result.target], baseRevision: state.revision)
393419 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 }
396421 return "Refiled to \(target.label)"
397422 } catch {
398423 return "Can't refile: \(error.localizedDescription)"
399424 }
400425 }
401426
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.
403429 @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." }
406432 let destination: Archive.Destination
407433 switch Archive.destination(context, sourcePath: path) {
408434 case .success(let value): destination = value
@@ -423,8 +449,7 @@ public final class DocumentSession {
423449 if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
424450 changed(open)
425451 }
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 }
428453 return "Archived to \(name)"
429454 }
430455 do {
@@ -441,8 +466,7 @@ public final class DocumentSession {
441466 try state.apply([archiveEdit], baseRevision: state.revision)
442467 if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." }
443468 }
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 }
446470 return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)"
447471 } catch {
448472 return "Can't archive: \(error.localizedDescription)"
@@ -451,6 +475,30 @@ public final class DocumentSession {
451475
452476 // MARK: - Commands from other views
453477
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
454502 /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does:
455503 /// through the buffer when the file is open, leaving its caret alone; otherwise on the
456504 /// 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 {
5454 }
5555 tags = Self.tags(from: answer)
5656 }
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
5863 try buffer.saveExcursion {
5964 try buffer.backToHeading()
6065 try buffer.saveExcursion {
@@ -79,3 +84,27 @@ public struct SetTags: OrgCommand {
7984 }
8085 }
8186}
87
88/// `org-toggle-tag` with `on` or `off`: adds or removes one tag, as the agenda's bulk tag
89/// actions do.
90public 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 @@
11import OrgApp
2import OrgDocument
23import OrgCore
34import OrgPresentation
45import SwiftUI
@@ -7,13 +8,15 @@ import SwiftUI
78/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
89/// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters
910/// (`+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
1113/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
1214struct AgendaView: View {
1315 let workspace: WorkspaceModel
1416 let session: DocumentSession
1517 let commands: AppCommands
1618 let scheduler: ReminderScheduler?
19 let clock: ClockModel
1720 @State private var agenda: AgendaModel
1821 @State private var views = AgendaViewLoader.builtIn
1922 @State private var viewName = AgendaViewLoader.builtIn[0].name
@@ -23,12 +26,21 @@ struct AgendaView: View {
2326 @State private var message: String?
2427 @State private var prompt: PendingPrompt?
2528 @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] = []
2732 @State private var logMode = false
2833 @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.
3035 @State private var filterPrompt: (title: String, kind: Character)?
3136 @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 = ""
3244 @AppStorage("agendaSpan") private var span = 10
3345 @AppStorage("agendaStartOffset") private var startOffset = -3
3446 @AppStorage("agendaSubfolders") private var includeSubfolders = false
@@ -40,14 +52,29 @@ struct AgendaView: View {
4052 static let match = "Match…"
4153 static let todoMatch = "TODO Match…"
4254
43 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) {
55 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) {
4456 self.workspace = workspace
4557 self.session = session
4658 self.commands = commands
4759 self.scheduler = scheduler
60 self.clock = clock
4861 _agenda = State(initialValue: AgendaModel(workspace: workspace))
4962 }
5063
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
5178 struct PendingPrompt: Identifiable {
5279 let id = UUID()
5380 let prompt: Prompt
@@ -197,6 +224,21 @@ struct AgendaView: View {
197224 Text(shown.tags.sorted().prefix(20).joined(separator: " "))
198225 }
199226 }
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 }
200242 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
201243 .onChange(of: viewName) { applyView() }
202244 .task(id: refreshKey) {
@@ -221,7 +263,7 @@ struct AgendaView: View {
221263 @ViewBuilder
222264 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
223265 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)
225267 .tag("\(prefix):\(index)")
226268 .selectionDisabled(item.path == nil)
227269 }
@@ -338,19 +380,36 @@ struct AgendaView: View {
338380
339381 private func handle(_ press: KeyPress) -> KeyPress.Result {
340382 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"
349408 }
350409 return .handled
351410 }
352 if control, press.characters == "c" || press.characters == "\u{03}" {
353 prefix = true
411 if isControl("c", "\u{03}") {
412 prefix = "C-c"
354413 message = "C-c-"
355414 return .handled
356415 }
@@ -378,6 +437,16 @@ struct AgendaView: View {
378437 case "g", "r":
379438 loadViews()
380439 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()
381450 case "t": run(TodoCycle())
382451 case "+": run(PriorityUp())
383452 case "-": run(PriorityDown())
@@ -387,6 +456,130 @@ struct AgendaView: View {
387456 return .handled
388457 }
389458
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
390583 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
391584 /// timestamp the entry is listed for.
392585 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
@@ -430,6 +623,8 @@ struct AgendaRow: View {
430623 var day: Int?
431624 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
432625 var customPrefix = false
626 /// Marked for a bulk action.
627 var marked = false
433628 @Environment(\.orgTheme) private var theme
434629
435630 var body: some View {
@@ -453,6 +648,15 @@ struct AgendaRow: View {
453648 .font(.caption)
454649 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
455650 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 {
456660 if customPrefix {
457661 HStack(spacing: 0) {
458662 Text(item.prefix)
@@ -495,7 +699,6 @@ struct AgendaRow: View {
495699 }
496700 }
497701 }
498 }
499702 }
500703
501704 static func trim(_ time: String) -> String {
Sources/Orgstar/OrgstarApp.swift +1 −1
@@ -250,7 +250,7 @@ struct OrgstarApp: App {
250250 .defaultSize(width: 1280, height: 860)
251251 Window("Agenda", id: "agenda") {
252252 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)
254254 .environment(\.orgTheme, ConfigSync.shared.theme)
255255 }
256256 }
Tests/OrgAppTests/AppTests.swift +41
@@ -305,6 +305,47 @@ struct MoveSubtreeTests {
305305 }
306306}
307307
308@MainActor
309struct 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
308349@MainActor
309350struct MoveIntoOpenBufferTests {
310351 /// 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 {
4747 #expect(mismatches == 0)
4848 }
4949}
50
51struct 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}