krz/orgstar

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

Sources/OrgApp/DocumentSession.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgApp/DocumentSession.swift history · blame · raw

890 lines · 43536 bytes

60 symbols in this file
  1import Foundation
  2import Observation
  3import OrgCore
  4import OrgDocument
  5import OrgIndex
  6import OrgPresentation
  7
  8/// A heading, or a whole file, a subtree can be refiled to.
  9public struct RefileTarget: Sendable, Hashable {
 10    /// `file.org/Parent/Heading`, as the prompt lists it.
 11    public let label: String
 12    public let path: String
 13    /// Start of the heading when the file had `contentHash`; nil refiles to the end of the file.
 14    public let start: Int?
 15    public let title: String
 16    public let level: Int
 17    public let contentHash: String
 18
 19    public init(label: String, path: String, start: Int?, title: String, level: Int, contentHash: String) {
 20        self.label = label
 21        self.path = path
 22        self.start = start
 23        self.title = title
 24        self.level = level
 25        self.contentHash = contentHash
 26    }
 27
 28    /// The heading's start in `state`: its indexed offset if the text is what was indexed,
 29    /// else the first heading with the same title and level. Nil for the end of the file.
 30    func locate(in state: DocumentState) -> Int?? {
 31        guard let start else { return .some(nil) }
 32        if !state.isDirty, FileRecord.hash(state.mergeBase) == contentHash { return .some(start) }
 33        let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level }
 34        return entry.map { .some($0.start) }
 35    }
 36}
 37
 38public enum SaveMode: String, Sendable, CaseIterable {
 39    /// Save shortly after typing stops.
 40    case automatic
 41    /// Save only on ⌘S.
 42    case explicit
 43}
 44
 45/// The files open in a window, as Emacs's buffers: each keeps its text, caret, undo and
 46/// unsaved edits while another is shown. The current buffer's outline, saving and requests
 47/// for the editor to show a location are what most views use. The view layer attaches an
 48/// editor to each buffer and follows `path` and `revealRequest`.
 49@MainActor
 50@Observable
 51public final class DocumentSession {
 52    /// The current buffer's file.
 53    public private(set) var path: String?
 54    /// A copy of the current buffer's state as of its last change. Not observed: it changes
 55    /// on every keystroke; views follow `path`, `isDirty` and `outline`.
 56    @ObservationIgnored public private(set) var document: DocumentState?
 57    public private(set) var outline: [OutlineEntry] = []
 58    /// Bumped when the buffer settles after edits, and when a file opens: views that read
 59    /// `document` follow this.
 60    public private(set) var editVersion = 0
 61    public private(set) var isDirty = false
 62    /// The file isn't UTF-8, so it can't be edited or saved.
 63    public private(set) var isReadOnly = false
 64    /// Set while the buffer and the file on disk conflict; says how to resolve it.
 65    public private(set) var conflict: String?
 66    /// Bumped to ask the editor to reveal an offset.
 67    public private(set) var revealRequest: (offset: Int, id: Int)?
 68    /// Open files, in the order buffers were opened (each after the one current then).
 69    public private(set) var openPaths: [String] = []
 70    /// Open files with unsaved edits.
 71    public private(set) var dirtyPaths: Set<String> = []
 72    public var lastError: String?
 73    public var saveMode: SaveMode = .automatic
 74    public var autosaveDelay: Duration = .seconds(1)
 75    /// Org options for commands the session runs (refile, archive).
 76    public var options: EditingOptions = .org
 77    /// TODO keywords and priorities for files without their own settings.
 78    public var defaults: OrgSettings = .default
 79
 80    /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names.
 81    func loaded(_ bytes: [UInt8], path: String) -> DocumentState {
 82        DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText))
 83    }
 84
 85    /// The settings of the file at `path` as the editor sees them: its open buffer's, or its
 86    /// text on disk with the defaults and the setup files it names.
 87    public func settings(for path: String) -> OrgSettings? {
 88        if let entry = entry(path) { return entry.buffer.document.tree.settings }
 89        guard let bytes = (try? fileSystem.read(URL(fileURLWithPath: path))).flatMap({ $0 }) else { return nil }
 90        return loaded(bytes, path: path).tree.settings
 91    }
 92
 93    /// A file's text, for setup files.
 94    func readText(_ file: String) -> String? {
 95        (try? fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) }
 96    }
 97
 98    /// C-c C-c on a keyword line: the open file's setup files read again (`org-mode-restart`).
 99    public func refreshSetup() {
100        guard let entry = current else { return }
101        resolveSetup(entry)
102        refresh()
103        editVersion += 1
104    }
105
106    /// The entry's setup files read again, as its text names them now.
107    private func resolveSetup(_ entry: Entry) {
108        let settings = defaults.resolvingSetupFiles(in: entry.buffer.document.text, path: entry.path, read: readText)
109        if settings != entry.buffer.document.defaults { entry.buffer.update { $0.setDefaults(settings) } }
110    }
111
112    /// One open file.
113    @MainActor
114    final class Entry {
115        let path: String
116        var buffer: DocumentBuffer
117        var conflict: String?
118        var autosave: Task<Void, Never>?
119
120        init(path: String, buffer: DocumentBuffer) {
121            self.path = path
122            self.buffer = buffer
123        }
124    }
125
126    private let fileSystem: FileSystem
127    private let recovery: RecoveryStore
128    @ObservationIgnored private var entries: [Entry] = []
129    @ObservationIgnored private var current: Entry?
130    /// Paths, most recently shown first.
131    @ObservationIgnored private var history: [String] = []
132    @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
133    private var requestID = 0
134
135    public init(
136        fileSystem: FileSystem = CoordinatedFileSystem(),
137        recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))
138    ) {
139        self.fileSystem = fileSystem
140        self.recovery = recovery
141    }
142
143    private var buffer: DocumentBuffer? { current?.buffer }
144    /// The current buffer's pending autosave, for tests to wait on.
145    var autosave: Task<Void, Never>? { current?.autosave }
146    private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
147    /// Whether the open file is an org file (`.org`, `.org_archive`), not plain text.
148    public var isOrg: Bool { path.map(Self.isOrgPath) ?? false }
149
150    public static func isOrgPath(_ path: String) -> Bool {
151        path.hasSuffix(".org") || path.hasSuffix(".org_archive")
152    }
153
154    /// The caret in the open file.
155    public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
156    private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
157
158    /// The open buffer of `path`, or of the same file by another path (`find-buffer-visiting`
159    /// by truename).
160    private func entry(_ path: String) -> Entry? {
161        if let exact = entries.first(where: { $0.path == path }) { return exact }
162        let truename = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
163        return entries.first { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path == truename }
164    }
165
166    /// The buffer's name as uniquify writes it (`post-forward-angle-brackets`): the file name,
167    /// with as many of its folders as tell it apart from other open files of the same name.
168    public func bufferName(_ path: String) -> String {
169        let name = (path as NSString).lastPathComponent
170        let others = openPaths.filter { $0 != path && ($0 as NSString).lastPathComponent == name }
171        guard !others.isEmpty else { return name }
172        func folders(_ p: String) -> [String] { (p as NSString).deletingLastPathComponent.split(separator: "/").map(String.init).reversed() }
173        let mine = folders(path)
174        var count = 1
175        while count < mine.count, others.contains(where: { folders($0).prefix(count) == mine.prefix(count) }) { count += 1 }
176        return name + "<" + mine.prefix(count).reversed().joined(separator: "/") + ">"
177    }
178
179    public func isOpen(_ path: String) -> Bool { entry(path) != nil }
180
181    /// The caret of an open file.
182    public func caret(of path: String) -> Int? { entry(path)?.buffer.selection.first?.lowerBound }
183
184    /// The text of every open file, for views that read files (the agenda, search).
185    public var openDocuments: [(path: String, document: DocumentState)] {
186        entries.map { ($0.path, $0.buffer.document) }
187    }
188
189    /// The text of every open file by path, for the agenda.
190    public var openTexts: [String: String] {
191        Dictionary(entries.map { ($0.path, $0.buffer.document.text) }, uniquingKeysWith: { a, _ in a })
192    }
193
194    /// Open buffers, most recently shown first.
195    public var recentPaths: [String] { history.filter(isOpen) }
196
197    /// Shows the buffer of `path`, opening the file if it isn't open.
198    public func open(_ path: String) {
199        if let existing = entry(path) {
200            activate(existing)
201            return
202        }
203        do {
204            guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
205                lastError = "\((path as NSString).lastPathComponent) no longer exists."
206                return
207            }
208            let entry = Entry(path: path, buffer: StateBuffer(loaded(bytes, path: path)))
209            let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count
210            entries.insert(entry, at: index)
211            activate(entry)
212        } catch {
213            lastError = "Can't open \(path): \(error.localizedDescription)"
214        }
215    }
216
217    private func activate(_ entry: Entry) {
218        current = entry
219        path = entry.path
220        conflict = entry.conflict
221        revealRequest = nil
222        history.removeAll { $0 == entry.path }
223        history.insert(entry.path, at: 0)
224        refresh()
225    }
226
227    /// The buffer before or after the current one, wrapping.
228    public func cycle(_ step: Int) {
229        guard let current, let index = entries.firstIndex(where: { $0 === current }), entries.count > 1 else { return }
230        activate(entries[((index + step) % entries.count + entries.count) % entries.count])
231    }
232
233    /// The buffer shown before the current one.
234    public func showPrevious() {
235        guard let previous = recentPaths.dropFirst().first else { return }
236        open(previous)
237    }
238
239    /// Closes the buffer of `path` without saving it; the most recent other buffer shows.
240    public func kill(_ path: String) {
241        guard let entry = entry(path) else { return }
242        entry.autosave?.cancel()
243        entries.removeAll { $0 === entry }
244        history.removeAll { $0 == path }
245        if entry === current {
246            current = nil
247            if let next = recentPaths.first.flatMap(self.entry) ?? entries.last {
248                activate(next)
249            } else {
250                outlineUpdate?.cancel()
251                self.path = nil
252                document = nil
253                outline = []
254                isDirty = false
255                conflict = nil
256                revealRequest = nil
257                refreshLists()
258            }
259        } else {
260            refreshLists()
261        }
262    }
263
264    /// Closes the current buffer without saving, as when its file was deleted.
265    public func close() {
266        if let path { kill(path) }
267    }
268
269    /// Makes `buffer`, built from `document`, the live state of the current file.
270    public func attach(_ buffer: DocumentBuffer) {
271        current?.buffer = buffer
272        refresh()
273    }
274
275    /// The buffer of `path` changed in its editor: the current one updates its views, another
276    /// saves on its own.
277    public func bufferChanged(_ path: String) {
278        guard let entry = entry(path) else { return }
279        if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
280    }
281
282    /// The buffer changed through typing. The outline follows once typing pauses.
283    public func bufferChanged() {
284        refresh(outline: false)
285        outlineUpdate?.cancel()
286        outlineUpdate = Task { [weak self] in
287            try? await Task.sleep(for: .milliseconds(300))
288            guard !Task.isCancelled, let self, let document = self.document else { return }
289            self.outline = self.isOrg ? Presentation.outline(document.tree) : []
290            self.editVersion += 1
291        }
292        if let current { scheduleAutosave(current) }
293    }
294
295    private func refresh(outline updateOutline: Bool = true) {
296        refreshLists()
297        guard let buffer else { return }
298        let state = buffer.document
299        document = state
300        if isDirty != state.isDirty { isDirty = state.isDirty }
301        if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
302        if conflict != current?.conflict { conflict = current?.conflict }
303        if updateOutline {
304            outline = isOrg ? Presentation.outline(state.tree) : []
305            editVersion += 1
306        }
307    }
308
309    private func refreshLists() {
310        let paths = entries.map(\.path)
311        if paths != openPaths { openPaths = paths }
312        let dirty = Set(entries.filter { $0.buffer.document.isDirty }.map(\.path))
313        if dirty != dirtyPaths { dirtyPaths = dirty }
314    }
315
316    // MARK: - Saving
317
318    private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
319
320    private func scheduleAutosave(_ entry: Entry) {
321        entry.autosave?.cancel()
322        guard saveMode == .automatic, entry.buffer.document.isDirty, entry.conflict == nil else { return }
323        entry.autosave = Task { [weak self, weak entry, autosaveDelay] in
324            try? await Task.sleep(for: autosaveDelay)
325            guard !Task.isCancelled, let self, let entry else { return }
326            self.save(entry)
327        }
328    }
329
330    /// Saves the current buffer now. True when nothing is left unsaved.
331    @discardableResult
332    public func save() -> Bool {
333        guard let current else { return true }
334        return save(current)
335    }
336
337    /// Saves the buffer of `path`. True when nothing is left unsaved.
338    @discardableResult
339    public func save(_ path: String) -> Bool {
340        guard let entry = entry(path) else { return true }
341        return save(entry)
342    }
343
344    /// Saves every open buffer. True when nothing is left unsaved.
345    @discardableResult
346    public func saveAll() -> Bool {
347        entries.map { save($0) }.allSatisfy { $0 }
348    }
349
350    @discardableResult
351    private func save(_ entry: Entry) -> Bool {
352        entry.autosave?.cancel()
353        let name = (entry.path as NSString).lastPathComponent
354        guard entry.buffer.document.isDirty else { return true }
355        guard entry.conflict == nil else { return false }
356        do {
357            let outcome = try entry.buffer.update { try saver.save(&$0, to: URL(fileURLWithPath: entry.path)) }
358            switch outcome {
359            case .saved, .mergedAndSaved:
360                break
361            case .conflict:
362                entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
363            case .overwroteExternalChange(let change), .changedAfterWrite(let change):
364                if case .conflict = change {
365                    entry.conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
366                }
367            }
368        } catch {
369            lastError = "Can't save \(name): \(error.localizedDescription)"
370        }
371        if entry === current { refresh() } else { refreshLists() }
372        return !entry.buffer.document.isDirty
373    }
374
375    /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
376    public func keepMine() {
377        guard let buffer, let url else { return }
378        do {
379            try buffer.update { try saver.overwrite(&$0, to: url) }
380            current?.conflict = nil
381        } catch {
382            lastError = "Can't save \(name): \(error.localizedDescription)"
383        }
384        refresh()
385    }
386
387    /// Resolves a conflict by loading the disk version; the buffer goes to recovery.
388    public func useDiskVersion() {
389        guard let buffer, let url else { return }
390        do {
391            guard let bytes = try fileSystem.read(url) else { return }
392            if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
393            let path = url.path
394            buffer.update { $0 = loaded(bytes, path: path) }
395            current?.conflict = nil
396        } catch {
397            lastError = "Can't read \(name): \(error.localizedDescription)"
398        }
399        refresh()
400    }
401
402    // MARK: - Comparing versions
403
404    /// The buffer, the file on disk and the version both started from, while they conflict.
405    public func conflictVersions() -> (mine: String, disk: String, base: String)? {
406        guard current?.conflict != nil, let buffer, let url, let disk = try? fileSystem.read(url) else { return nil }
407        return (buffer.document.text, SourceText(bytes: disk).text, SourceText(bytes: buffer.document.mergeBase).text)
408    }
409
410    /// Resolves a conflict as `git merge` leaves one: the buffer holds both sides' changes, those
411    /// that conflict between markers, to fix and save. The disk version becomes the base, and
412    /// the buffer as it was goes to recovery.
413    public func mergeConflictWithMarkers() {
414        guard let versions = conflictVersions(), let buffer, let url, let disk = try? fileSystem.read(url) else { return }
415        do {
416            try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
417            let merged = mergeWithMarkers(base: versions.base, ours: versions.mine, theirs: versions.disk, oursLabel: "yours", theirsLabel: "disk")
418            try buffer.update { state in
419                state = loaded(disk, path: url.path)
420                try state.apply(lineEdits(from: versions.disk, to: merged), baseRevision: state.revision)
421            }
422            current?.conflict = nil
423        } catch {
424            lastError = "Can't merge \(name): \(error.localizedDescription)"
425        }
426        refresh()
427    }
428
429    /// Syncthing's conflict copies of the open file beside it: `name.sync-conflict-DATE-TIME-ID.ext`.
430    public func syncConflictCopies() -> [URL] {
431        guard let url else { return [] }
432        let folder = url.deletingLastPathComponent()
433        let stem = NSRegularExpression.escapedPattern(for: url.deletingPathExtension().lastPathComponent)
434        let ext = url.pathExtension.isEmpty ? "" : "\\." + NSRegularExpression.escapedPattern(for: url.pathExtension)
435        let pattern = "^" + stem + "\\.sync-conflict-[0-9]{8}-[0-9]{6}-[A-Z0-9]+" + ext + "$"
436        let names = (try? FileManager.default.contentsOfDirectory(atPath: folder.path)) ?? []
437        return names.filter { $0.range(of: pattern, options: .regularExpression) != nil }.sorted().map { folder.appendingPathComponent($0) }
438    }
439
440    /// What to do with a conflict copy: keep the file as it is, take the copy's text, or merge
441    /// the two with markers. The copy goes to recovery and is removed.
442    public enum CopyResolution { case keepFile, useCopy, merge }
443
444    public func resolve(copy: URL, _ resolution: CopyResolution) -> String? {
445        guard let buffer, let url else { return "No file is open" }
446        do {
447            guard let bytes = try fileSystem.read(copy) else { return "\(copy.lastPathComponent) is gone" }
448            let copyText = SourceText(bytes: bytes).text
449            let mine = buffer.document.text
450            let new: String? = switch resolution {
451            case .keepFile: nil
452            case .useCopy: copyText
453            case .merge: mergeWithMarkers(base: nil, ours: mine, theirs: copyText, oursLabel: url.lastPathComponent, theirsLabel: copy.lastPathComponent)
454            }
455            if let new, new != mine {
456                let result = EditResult(baseRevision: buffer.document.revision, edits: lineEdits(from: mine, to: new))
457                if case .failed(let message) = buffer.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
458            }
459            try recovery.keep(bytes, for: url, label: "sync-conflict")
460            try FileManager.default.removeItem(at: copy)
461        } catch {
462            return "Can't resolve \(copy.lastPathComponent): \(error.localizedDescription)"
463        }
464        if let current { changed(current) }
465        return nil
466    }
467
468    /// Versions of the open file kept in recovery, newest first, with when and why each was
469    /// kept.
470    public func recoveryVersions() -> [(url: URL, date: Date, label: String)] {
471        guard let url, let store = recovery as? FileRecoveryStore, let versions = try? store.versions(for: url) else { return [] }
472        // `STAMP-LABEL-UUID8-NAME`, as `FileRecoveryStore.keep` names them.
473        return versions.reversed().map { version in
474            let m = version.lastPathComponent.firstMatch(of: /^([0-9]+)-(.+?)-([0-9A-Fa-f]{8})-/)
475            let nanos = m.flatMap { UInt64($0.1) } ?? 0
476            return (version, Date(timeIntervalSince1970: Double(nanos) / 1_000_000_000), m.map { String($0.2) } ?? "")
477        }
478    }
479
480    /// The text of a kept version.
481    public func recoveryText(_ version: URL) -> String? {
482        (try? fileSystem.read(version)).flatMap { $0 }.map { SourceText(bytes: $0).text }
483    }
484
485    /// Puts a kept version's text in the buffer, as an edit that can be undone; the buffer as it
486    /// was goes to recovery.
487    public func restore(_ version: URL) -> String? {
488        guard let buffer, let url, let text = recoveryText(version) else { return "Can't read that version" }
489        do {
490            try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
491        } catch {
492            return "Can't keep the current version: \(error.localizedDescription)"
493        }
494        let mine = buffer.document.text
495        let result = EditResult(baseRevision: buffer.document.revision, edits: lineEdits(from: mine, to: text))
496        if case .failed(let message) = buffer.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
497        if let current { changed(current) }
498        return nil
499    }
500
501    // MARK: - Moving subtrees
502
503    /// The current context of the buffer, as commands see it.
504    private func editContext(now: Date) -> EditContext? {
505        guard let buffer else { return nil }
506        let document = buffer.document
507        return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options)
508    }
509
510    /// The file and context a subtree command works on: `from` (a file and a heading in it,
511    /// as the agenda gives them), or the current buffer at its caret.
512    private func source(_ from: (path: String, offset: Int)?, now: Date) -> (path: String, context: EditContext)? {
513        guard let from else {
514            guard let path, let context = editContext(now: now) else { return nil }
515            return (path, context)
516        }
517        let state: DocumentState
518        if let open = entry(from.path) {
519            state = open.buffer.document
520        } else {
521            guard let bytes = try? fileSystem.read(URL(fileURLWithPath: from.path)) else { return nil }
522            state = loaded(bytes, path: from.path)
523        }
524        guard state.isEditable else { return nil }
525        let context = EditContext(revision: state.revision, text: state.text, tree: state.tree, selection: [from.offset..<from.offset], now: now, options: options)
526        return (from.path, context)
527    }
528
529    /// Applies an edit computed from `source(_:now:)` to that file, open or not.
530    private func applySource(_ edit: EditResult, path: String, now: Date) -> String? {
531        if case .failed(let message) = perform(PrecomputedEdit(edit), path: path, at: 0, expect: nil, now: now) { return message }
532        return nil
533    }
534
535    /// Moves the subtree at the caret, or at `from`, under `target`. For another file, that
536    /// file is written first, through the save path; the subtree leaves its file only once it
537    /// has. Returns a message for the echo area.
538    @discardableResult
539    public func refile(to target: RefileTarget, from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? {
540        guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." }
541        if target.path == path {
542            let answers = ["refile": target.start.map(String.init) ?? ""]
543            let step = from == nil ? buffer?.perform(RefileWithinFile(), now: now, answers: answers)
544                : perform(RefileWithinFile(), path: path, at: from!.offset, expect: nil, answers: answers, now: now)
545            if case .failed(let message)? = step { return message }
546            if from == nil { bufferChanged() }
547            return "Refiled to \(target.label)"
548        }
549        let url = URL(fileURLWithPath: target.path)
550        let targetName = (target.path as NSString).lastPathComponent
551        // A target open in a buffer takes the subtree there, with its unsaved edits.
552        if let open = entry(target.path) {
553            let state = open.buffer.document
554            guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." }
555            guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
556            guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
557            let edit = EditResult(baseRevision: state.revision, edits: [result.target])
558            if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
559            changed(open)
560            if let message = applySource(result.source, path: path, now: now) { return message }
561            return "Refiled to \(target.label)"
562        }
563        do {
564            guard let bytes = try fileSystem.read(url) else { return "\(targetName) no longer exists." }
565            var state = loaded(bytes, path: url.path)
566            guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." }
567            guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
568            guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
569            try state.apply([result.target], baseRevision: state.revision)
570            if case .conflict = try saver.save(&state, to: url) { return "\(targetName) changed on disk; nothing was refiled." }
571            if let message = applySource(result.source, path: path, now: now) { return message }
572            return "Refiled to \(target.label)"
573        } catch {
574            return "Can't refile: \(error.localizedDescription)"
575        }
576    }
577
578    /// Archives the subtree at the caret, or at `from`, where `org-archive-subtree` would.
579    /// Returns a message.
580    @discardableResult
581    public func archive(from: (path: String, offset: Int)? = nil, now: Date = Date()) -> String? {
582        guard let (path, context) = source(from, now: now) else { return from == nil ? nil : "Can't read the entry's file." }
583        let destination: Archive.Destination
584        switch Archive.destination(context, sourcePath: path) {
585        case .success(let value): destination = value
586        case .failure(.message(let message)): return message
587        }
588        // An archive file open in a buffer takes the subtree there.
589        if !destination.isSameFile, let open = entry(destination.path) {
590            let state = open.buffer.document
591            let name = (destination.path as NSString).lastPathComponent
592            guard state.isEditable else { return "\(name) isn't UTF-8, so it can't be changed." }
593            let edits: Archive.Edits
594            switch Archive.run(context, sourcePath: path, destination: destination, archiveText: state.text) {
595            case .success(let value): edits = value
596            case .failure(.message(let message)): return message
597            }
598            if let archiveEdit = edits.archive {
599                let edit = EditResult(baseRevision: state.revision, edits: [archiveEdit])
600                if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
601                changed(open)
602            }
603            if let message = applySource(edits.source, path: path, now: now) { return message }
604            return "Archived to \(name)"
605        }
606        do {
607            let url = URL(fileURLWithPath: destination.path)
608            let existing = destination.isSameFile ? nil : try fileSystem.read(url)
609            var state = loaded(existing ?? [], path: url.path)
610            if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
611            let edits: Archive.Edits
612            switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
613            case .success(let value): edits = value
614            case .failure(.message(let message)): return message
615            }
616            if let archiveEdit = edits.archive {
617                try state.apply([archiveEdit], baseRevision: state.revision)
618                if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." }
619            }
620            if let message = applySource(edits.source, path: path, now: now) { return message }
621            return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)"
622        } catch {
623            return "Can't archive: \(error.localizedDescription)"
624        }
625    }
626
627    // MARK: - Commands from other views
628
629    /// Where the heading line `line` is now in `path`: at `offset` if it still reads so, else
630    /// the copy of the line nearest `offset`. For acting on agenda entries after other edits
631    /// moved them.
632    public func locateHeading(path: String, line: String, near offset: Int) -> Int? {
633        guard let text = entry(path)?.buffer.document.text ?? (try? fileSystem.read(URL(fileURLWithPath: path))).flatMap({ $0.map { SourceText(bytes: $0).text } }) else { return nil }
634        let ns = text as NSString
635        let length = (line as NSString).length
636        if offset + length <= ns.length, ns.substring(with: NSRange(location: offset, length: length)) == line,
637           offset == 0 || ns.character(at: offset - 1) == 10 {
638            return offset
639        }
640        var best: Int?
641        var search = NSRange(location: 0, length: ns.length)
642        while true {
643            let found = ns.range(of: line, options: [], range: search)
644            guard found.location != NSNotFound else { break }
645            let atLineStart = found.location == 0 || ns.character(at: found.location - 1) == 10
646            let lineEnd = NSMaxRange(found) == ns.length || ns.character(at: NSMaxRange(found)) == 10
647            if atLineStart, lineEnd, best.map({ abs($0 - offset) > abs(found.location - offset) }) ?? true { best = found.location }
648            search = NSRange(location: NSMaxRange(found), length: ns.length - NSMaxRange(found))
649        }
650        return best
651    }
652
653    /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does:
654    /// through the buffer when the file is open, leaving its caret alone; otherwise on the
655    /// file, written back at once through the save path. `expect` is the heading line the
656    /// caller saw at a position; if the text there changed, nothing runs.
657    public func perform(
658        _ command: any OrgCommand, path: String, at offset: Int, expect: (start: Int, line: String)?,
659        answers: [String: String] = [:], now: Date = Date()
660    ) -> CommandStep {
661        let stale = CommandStep.failed("The entry changed; the agenda has been refreshed.")
662        func matches(_ text: String) -> Bool {
663            guard let expect else { return true }
664            let ns = text as NSString
665            guard expect.start + (expect.line as NSString).length <= ns.length else { return false }
666            return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line
667        }
668        if let entry = entry(path) {
669            let buffer = entry.buffer
670            guard matches(buffer.document.text) else { return stale }
671            do {
672                let options = self.options
673                let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) }
674                if case .commit = step {
675                    if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
676                }
677                return step
678            } catch {
679                return .failed("\(error)")
680            }
681        }
682        let url = URL(fileURLWithPath: path)
683        let name = url.lastPathComponent
684        do {
685            guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") }
686            var state = loaded(bytes, path: path)
687            guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") }
688            guard matches(state.text) else { return stale }
689            let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options)
690            if case .commit = step, state.isDirty, case .conflict = try saver.save(&state, to: url) {
691                return .failed("\(name) changed on disk; nothing was changed.")
692            }
693            return step
694        } catch {
695            return .failed("Can't change \(name): \(error.localizedDescription)")
696        }
697    }
698
699    // MARK: - External work
700
701    /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
702    /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
703    public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
704        #if os(macOS)
705        guard let entry = current else { return "No file is open." }
706        let path = entry.path
707        let snapshot = entry.buffer.document.text as NSString
708        guard table.upperBound <= snapshot.length else { return "The table moved; try again." }
709        let old = snapshot.substring(with: NSRange(table))
710        let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
711        do {
712            let new = try await EmacsRunner.recalculateTable(text: snapshot as String, directory: directory, table: table, point: point, form: form)
713            guard entries.contains(where: { $0 === entry }) else { return "The file was closed before Emacs finished." }
714            let current = entry.buffer
715            let text = current.document.text as NSString
716            guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else {
717                return "The table changed while Emacs recalculated it; nothing was changed."
718            }
719            guard new != old else { return "Recalculated in Emacs" }
720            let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)])
721            if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
722            changed(entry)
723            return "Recalculated in Emacs"
724        } catch is CancellationError {
725            return "Recalculation canceled"
726        } catch {
727            return "\(error)"
728        }
729        #else
730        return "Tables that need Emacs aren't recalculated on iOS."
731        #endif
732    }
733
734    /// Runs a src block and writes its result under it, unless the block changed while it
735    /// ran. Returns a message for the echo area.
736    public func runBabel(_ job: BabelJob) async -> String {
737        guard let entry = current else { return "No file is open." }
738        let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
739        /// A program on the Mac; on iOS, Emacs Lisp in `Elisp`, and nothing else.
740        func run(_ job: BabelJob) async throws -> BabelOutput {
741            #if os(macOS)
742            return try await BabelRunner.run(job, fileDirectory: directory)
743            #else
744            guard let output = try Babel.evaluateNatively(job) else {
745                throw Babel.Failure.message(job.lispForm == nil
746                    ? "\(job.language) blocks need the Mac to run."
747                    : "This block uses Emacs Lisp that runs only in Emacs, on the Mac.")
748            }
749            return output
750            #endif
751        }
752        do {
753            // Blocks the `:var`s refer to run first, one at a time.
754            // Each is one of the blocks agreed to, or the run stops: the text may have changed
755            // while a block ran.
756            var job = job
757            let approved = job.approved
758            func agreed(_ block: BabelJob) -> Bool { approved.isEmpty || approved.contains(block.blockText) }
759            if job.needsFile {
760                let document = entry.buffer.document
761                switch Babel.replan(job, file: entry.path, text: document.text, tree: document.tree) {
762                case .failure(.message(let message)): return message
763                case .success(let next): job = next
764                }
765            }
766            while let need = job.needs.first {
767                guard agreed(need.job) else { return "The blocks changed while running; nothing more was run." }
768                let output = try await run(need.job)
769                guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
770                let document = entry.buffer.document
771                switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) {
772                case .failure(.message(let message)): return message
773                case .success(let next): job = next
774                }
775            }
776            guard agreed(job) else { return "The blocks changed while running; nothing more was run." }
777            let output = try await run(job)
778            guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
779            if let file = Babel.fileResult(job, output: output) {
780                let runDirectory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory
781                let target = file.path.hasPrefix("/") ? URL(fileURLWithPath: file.path) : runDirectory.appendingPathComponent(file.path)
782                do {
783                    try file.contents.write(to: target, atomically: true, encoding: .utf8)
784                } catch {
785                    return "Can't write \(file.path): \(error.localizedDescription)"
786                }
787            }
788            let current = entry.buffer
789            let document = current.document
790            let done = output.problem ?? "Code block evaluation complete."
791            switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
792            case .failure(.message(let message)):
793                return message
794            case .success(let edits):
795                guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.problem ?? output.stdout : done }
796                let result = EditResult(baseRevision: document.revision, edits: edits)
797                if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
798                changed(entry)
799                return done
800            }
801        } catch is CancellationError {
802            return "Code block canceled."
803        } catch Babel.Failure.message(let message) {
804            return message
805        } catch {
806            return "\(error)"
807        }
808    }
809
810    /// The block running now, to cancel.
811    @ObservationIgnored private var babelTask: Task<String, Never>?
812    public private(set) var isRunningBabel = false
813
814    /// Runs a src block in the background; its message comes back through `report`.
815    public func startBabel(_ job: BabelJob, report: @escaping @MainActor (String) -> Void) {
816        babelTask?.cancel()
817        isRunningBabel = true
818        let task = Task { await runBabel(job) }
819        babelTask = task
820        Task {
821            let message = await task.value
822            if babelTask == task {
823                babelTask = nil
824                isRunningBabel = false
825            }
826            report(message)
827        }
828    }
829
830    /// Stops the running block; its results aren't inserted.
831    public func cancelBabel() {
832        babelTask?.cancel()
833    }
834
835    /// A buffer changed outside typing: the current one updates its views, another saves.
836    private func changed(_ entry: Entry) {
837        if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
838    }
839
840    // MARK: - Locations
841
842    /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
843    /// are used only if the buffer matches what was indexed; otherwise the heading is found
844    /// again by position and title.
845    public func show(_ location: HeadingLocation) {
846        if path != location.path { open(location.path) }
847        guard let document else { return }
848        let hash = FileRecord.hash(document.mergeBase)
849        let offset: Int
850        if !isDirty, hash == location.contentHash {
851            offset = location.start
852        } else if location.ordinal == HeadingLocation.fileOrdinal {
853            offset = 0
854        } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
855            offset = outline[location.ordinal].start
856        } else if let entry = outline.first(where: { $0.title == location.title }) {
857            offset = entry.start
858        } else {
859            return
860        }
861        reveal(offset)
862    }
863
864    public func reveal(_ offset: Int) {
865        requestID += 1
866        revealRequest = (offset, requestID)
867    }
868
869    /// Called when the workspace sees a file change on disk: an open buffer of it takes the
870    /// change in.
871    public func fileChanged(_ path: String) {
872        guard let entry = entry(path), let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
873        let buffer = entry.buffer
874        guard bytes != buffer.document.mergeBase else { return }
875        let name = (path as NSString).lastPathComponent
876        switch buffer.update({ $0.diskChanged(to: bytes) }) {
877        case .conflict:
878            entry.autosave?.cancel()
879            entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
880        case .merged, .reloaded:
881            entry.conflict = nil
882            resolveSetup(entry)
883            // Edits merged with the disk's still need saving.
884            scheduleAutosave(entry)
885        case .unchanged:
886            break
887        }
888        if entry === current { refresh() } else { refreshLists() }
889    }
890}