Sources/OrgApp/DocumentSession.swift
890 lines · 43536 bytes
60 symbols in this file
RefileTargetlocateSaveModeDocumentSessionloadedsettingsreadTextrefreshSetupresolveSetupEntryisOrgPathentrybufferNamefoldersisOpencaretopenactivatecycleshowPreviouskillcloseattachbufferChangedbufferChangedrefreshrefreshListsscheduleAutosavesavesavesaveAllsavekeepMineuseDiskVersionconflictVersionsmergeConflictWithMarkerssyncConflictCopiesCopyResolutionresolverecoveryVersionsrecoveryTextrestoreeditContextsourceapplySourcerefilearchivelocateHeadingperformmatchesrecalculateInEmacsrunBabelrunagreedstartBabelcancelBabelchangedshowrevealfileChanged
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}