Commit 99990e7b68
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +67 −19
| @@ -356,16 +356,43 @@ public final class DocumentSession { | |||
| 356 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options) | 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 | 359 | /// The file and context a subtree command works on: `from` (a file and a heading in it, |
| 360 | /// first, through the save path; the subtree leaves this buffer only once it has. Returns a | 360 | /// as the agenda gives them), or the current buffer at its caret. |
| 361 | /// message for the echo area. | 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 | @discardableResult | 387 | @discardableResult |
| 363 | public func refile(to target: RefileTarget, now: Date = Date()) -> String? { | 388 | public func refile(to target: RefileTarget, from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? { |
| 364 | guard let buffer, let path, let context = editContext(now: now) else { return nil } | 389 | guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." } |
| 365 | if target.path == path { | 390 | if target.path == path { |
| 366 | let step = buffer.perform(RefileWithinFile(), now: now, answers: ["refile": target.start.map(String.init) ?? ""]) | 391 | let answers = ["refile": target.start.map(String.init) ?? ""] |
| 367 | if case .failed(let message) = step { return message } | 392 | let step = from == nil ? buffer?.perform(RefileWithinFile(), now: now, answers: answers) |
| 368 | bufferChanged() | 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 | return "Refiled to \(target.label)" | 396 | return "Refiled to \(target.label)" |
| 370 | } | 397 | } |
| 371 | let url = URL(fileURLWithPath: target.path) | 398 | let url = URL(fileURLWithPath: target.path) |
| @@ -379,8 +406,7 @@ public final class DocumentSession { | |||
| 379 | let edit = EditResult(baseRevision: state.revision, edits: [result.target]) | 406 | let edit = EditResult(baseRevision: state.revision, edits: [result.target]) |
| 380 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } | 407 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } |
| 381 | changed(open) | 408 | changed(open) |
| 382 | if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message } | 409 | if let message = applySource(result.source, path: path, now: now) { return message } |
| 383 | bufferChanged() | ||
| 384 | return "Refiled to \(target.label)" | 410 | return "Refiled to \(target.label)" |
| 385 | } | 411 | } |
| 386 | do { | 412 | do { |
| @@ -391,18 +417,18 @@ public final class DocumentSession { | |||
| 391 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } | 417 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } |
| 392 | try state.apply([result.target], baseRevision: state.revision) | 418 | try state.apply([result.target], baseRevision: state.revision) |
| 393 | if case .conflict = try saver.save(&state, to: url) { return "\(targetName) changed on disk; nothing was refiled." } | 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 } | 420 | if let message = applySource(result.source, path: path, now: now) { return message } |
| 395 | bufferChanged() | ||
| 396 | return "Refiled to \(target.label)" | 421 | return "Refiled to \(target.label)" |
| 397 | } catch { | 422 | } catch { |
| 398 | return "Can't refile: \(error.localizedDescription)" | 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 | @discardableResult | 429 | @discardableResult |
| 404 | public func archive(now: Date = Date()) -> String? { | 430 | public func archive(from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? { |
| 405 | guard let buffer, let path, let context = editContext(now: now) else { return nil } | 431 | guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." } |
| 406 | let destination: Archive.Destination | 432 | let destination: Archive.Destination |
| 407 | switch Archive.destination(context, sourcePath: path) { | 433 | switch Archive.destination(context, sourcePath: path) { |
| 408 | case .success(let value): destination = value | 434 | case .success(let value): destination = value |
| @@ -423,8 +449,7 @@ public final class DocumentSession { | |||
| 423 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } | 449 | if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message } |
| 424 | changed(open) | 450 | changed(open) |
| 425 | } | 451 | } |
| 426 | if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message } | 452 | if let message = applySource(edits.source, path: path, now: now) { return message } |
| 427 | bufferChanged() | ||
| 428 | return "Archived to \(name)" | 453 | return "Archived to \(name)" |
| 429 | } | 454 | } |
| 430 | do { | 455 | do { |
| @@ -441,8 +466,7 @@ public final class DocumentSession { | |||
| 441 | try state.apply([archiveEdit], baseRevision: state.revision) | 466 | try state.apply([archiveEdit], baseRevision: state.revision) |
| 442 | if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." } | 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 } | 469 | if let message = applySource(edits.source, path: path, now: now) { return message } |
| 445 | bufferChanged() | ||
| 446 | return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)" | 470 | return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)" |
| 447 | } catch { | 471 | } catch { |
| 448 | return "Can't archive: \(error.localizedDescription)" | 472 | return "Can't archive: \(error.localizedDescription)" |
| @@ -451,6 +475,30 @@ public final class DocumentSession { | |||
| 451 | 475 | ||
| 452 | // MARK: - Commands from other views | 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 | /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does: | 502 | /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does: |
| 455 | /// through the buffer when the file is open, leaving its caret alone; otherwise on the | 503 | /// through the buffer when the file is open, leaving its caret alone; otherwise on the |
| 456 | /// file, written back at once through the save path. `expect` is the heading line the | 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 | tags = Self.tags(from: answer) | 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 | try buffer.saveExcursion { | 63 | try buffer.saveExcursion { |
| 59 | try buffer.backToHeading() | 64 | try buffer.backToHeading() |
| 60 | try buffer.saveExcursion { | 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 | import OrgApp | 1 | import OrgApp |
| 2 | import OrgDocument | ||
| 2 | import OrgCore | 3 | import OrgCore |
| 3 | import OrgPresentation | 4 | import OrgPresentation |
| 4 | import SwiftUI | 5 | import SwiftUI |
| @@ -7,13 +8,15 @@ import SwiftUI | |||
| 7 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: | 8 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: |
| 8 | /// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters | 9 | /// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters |
| 9 | /// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort, | 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 | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. | 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 | struct AgendaView: View { | 14 | struct AgendaView: View { |
| 13 | let workspace: WorkspaceModel | 15 | let workspace: WorkspaceModel |
| 14 | let session: DocumentSession | 16 | let session: DocumentSession |
| 15 | let commands: AppCommands | 17 | let commands: AppCommands |
| 16 | let scheduler: ReminderScheduler? | 18 | let scheduler: ReminderScheduler? |
| 19 | let clock: ClockModel | ||
| 17 | @State private var agenda: AgendaModel | 20 | @State private var agenda: AgendaModel |
| 18 | @State private var views = AgendaViewLoader.builtIn | 21 | @State private var views = AgendaViewLoader.builtIn |
| 19 | @State private var viewName = AgendaViewLoader.builtIn[0].name | 22 | @State private var viewName = AgendaViewLoader.builtIn[0].name |
| @@ -23,12 +26,21 @@ struct AgendaView: View { | |||
| 23 | @State private var message: String? | 26 | @State private var message: String? |
| 24 | @State private var prompt: PendingPrompt? | 27 | @State private var prompt: PendingPrompt? |
| 25 | @State private var promptText = "" | 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 | @State private var logMode = false | 32 | @State private var logMode = false |
| 28 | @State private var filter = AgendaFilter() | 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 | @State private var filterPrompt: (title: String, kind: Character)? | 35 | @State private var filterPrompt: (title: String, kind: Character)? |
| 31 | @State private var filterText = "" | 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 | @AppStorage("agendaSpan") private var span = 10 | 44 | @AppStorage("agendaSpan") private var span = 10 |
| 33 | @AppStorage("agendaStartOffset") private var startOffset = -3 | 45 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 34 | @AppStorage("agendaSubfolders") private var includeSubfolders = false | 46 | @AppStorage("agendaSubfolders") private var includeSubfolders = false |
| @@ -40,14 +52,29 @@ struct AgendaView: View { | |||
| 40 | static let match = "Match…" | 52 | static let match = "Match…" |
| 41 | static let todoMatch = "TODO Match…" | 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 | self.workspace = workspace | 56 | self.workspace = workspace |
| 45 | self.session = session | 57 | self.session = session |
| 46 | self.commands = commands | 58 | self.commands = commands |
| 47 | self.scheduler = scheduler | 59 | self.scheduler = scheduler |
| 60 | self.clock = clock | ||
| 48 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | 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 | struct PendingPrompt: Identifiable { | 78 | struct PendingPrompt: Identifiable { |
| 52 | let id = UUID() | 79 | let id = UUID() |
| 53 | let prompt: Prompt | 80 | let prompt: Prompt |
| @@ -197,6 +224,21 @@ struct AgendaView: View { | |||
| 197 | Text(shown.tags.sorted().prefix(20).joined(separator: " ")) | 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 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) | 242 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) |
| 201 | .onChange(of: viewName) { applyView() } | 243 | .onChange(of: viewName) { applyView() } |
| 202 | .task(id: refreshKey) { | 244 | .task(id: refreshKey) { |
| @@ -221,7 +263,7 @@ struct AgendaView: View { | |||
| 221 | @ViewBuilder | 263 | @ViewBuilder |
| 222 | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { | 264 | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { |
| 223 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | 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 | .tag("\(prefix):\(index)") | 267 | .tag("\(prefix):\(index)") |
| 226 | .selectionDisabled(item.path == nil) | 268 | .selectionDisabled(item.path == nil) |
| 227 | } | 269 | } |
| @@ -338,19 +380,36 @@ struct AgendaView: View { | |||
| 338 | 380 | ||
| 339 | private func handle(_ press: KeyPress) -> KeyPress.Result { | 381 | private func handle(_ press: KeyPress) -> KeyPress.Result { |
| 340 | let control = press.modifiers.contains(.control) | 382 | let control = press.modifiers.contains(.control) |
| 341 | if prefix { | 383 | // `C-x` and friends arrive as control characters or as letters with control. |
| 342 | prefix = false | 384 | func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) } |
| 343 | switch (control, press.characters) { | 385 | if let pending = prefix { |
| 344 | case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false)) | 386 | prefix = nil |
| 345 | case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true)) | 387 | if pending == "C-c C-x" { |
| 346 | case (true, "\u{11}"), (true, "q"): run(SetTags()) | 388 | if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" } |
| 347 | case (true, "\u{14}"), (true, "t"): run(TodoCycle()) | 389 | return .handled |
| 348 | default: message = "C-c \(press.characters) is undefined" | 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 | return .handled | 409 | return .handled |
| 351 | } | 410 | } |
| 352 | if control, press.characters == "c" || press.characters == "\u{03}" { | 411 | if isControl("c", "\u{03}") { |
| 353 | prefix = true | 412 | prefix = "C-c" |
| 354 | message = "C-c-" | 413 | message = "C-c-" |
| 355 | return .handled | 414 | return .handled |
| 356 | } | 415 | } |
| @@ -378,6 +437,16 @@ struct AgendaView: View { | |||
| 378 | case "g", "r": | 437 | case "g", "r": |
| 379 | loadViews() | 438 | loadViews() |
| 380 | tick += 1 | 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 | case "t": run(TodoCycle()) | 450 | case "t": run(TodoCycle()) |
| 382 | case "+": run(PriorityUp()) | 451 | case "+": run(PriorityUp()) |
| 383 | case "-": run(PriorityDown()) | 452 | case "-": run(PriorityDown()) |
| @@ -387,6 +456,130 @@ struct AgendaView: View { | |||
| 387 | return .handled | 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 | /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the | 583 | /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the |
| 391 | /// timestamp the entry is listed for. | 584 | /// timestamp the entry is listed for. |
| 392 | private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) { | 585 | private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) { |
| @@ -430,6 +623,8 @@ struct AgendaRow: View { | |||
| 430 | var day: Int? | 623 | var day: Int? |
| 431 | /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns. | 624 | /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns. |
| 432 | var customPrefix = false | 625 | var customPrefix = false |
| 626 | /// Marked for a bulk action. | ||
| 627 | var marked = false | ||
| 433 | @Environment(\.orgTheme) private var theme | 628 | @Environment(\.orgTheme) private var theme |
| 434 | 629 | ||
| 435 | var body: some View { | 630 | var body: some View { |
| @@ -453,6 +648,15 @@ struct AgendaRow: View { | |||
| 453 | .font(.caption) | 648 | .font(.caption) |
| 454 | .foregroundStyle(theme.color("agenda-today") ?? .accentColor) | 649 | .foregroundStyle(theme.color("agenda-today") ?? .accentColor) |
| 455 | default: | 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 | if customPrefix { | 660 | if customPrefix { |
| 457 | HStack(spacing: 0) { | 661 | HStack(spacing: 0) { |
| 458 | Text(item.prefix) | 662 | Text(item.prefix) |
| @@ -495,7 +699,6 @@ struct AgendaRow: View { | |||
| 495 | } | 699 | } |
| 496 | } | 700 | } |
| 497 | } | 701 | } |
| 498 | } | ||
| 499 | } | 702 | } |
| 500 | 703 | ||
| 501 | static func trim(_ time: String) -> String { | 704 | static func trim(_ time: String) -> String { |
Sources/Orgstar/OrgstarApp.swift +1 −1
| @@ -250,7 +250,7 @@ struct OrgstarApp: App { | |||
| 250 | .defaultSize(width: 1280, height: 860) | 250 | .defaultSize(width: 1280, height: 860) |
| 251 | Window("Agenda", id: "agenda") { | 251 | Window("Agenda", id: "agenda") { |
| 252 | if case .success(let model) = workspace { | 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 | .environment(\.orgTheme, ConfigSync.shared.theme) | 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 | @MainActor | 349 | @MainActor |
| 309 | struct MoveIntoOpenBufferTests { | 350 | struct MoveIntoOpenBufferTests { |
| 310 | /// A target open with unsaved edits above its heading takes the subtree in its buffer, | 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 | #expect(mismatches == 0) | 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 | } | ||