Sources/OrgApp/DocumentSession.swift
414 lines · 19526 bytes
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 file hasn't changed, else the
29 /// 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 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 file open in a window: its buffer, outline, saving, and requests for the editor to
46/// show a location. The view layer attaches the editor as the buffer and follows
47/// `revealRequest`.
48@MainActor
49@Observable
50public final class DocumentSession {
51 public private(set) var path: String?
52 /// A copy of the buffer's state as of its last change. Not observed: it changes on every
53 /// keystroke; views follow `path`, `isDirty` and `outline`.
54 @ObservationIgnored public private(set) var document: DocumentState?
55 public private(set) var outline: [OutlineEntry] = []
56 /// Bumped when the buffer settles after edits, and when a file opens: views that read
57 /// `document` follow this.
58 public private(set) var editVersion = 0
59 public private(set) var isDirty = false
60 /// The file isn't UTF-8, so it can't be edited or saved.
61 public private(set) var isReadOnly = false
62 /// Set while the buffer and the file on disk conflict; says how to resolve it.
63 public private(set) var conflict: String?
64 /// Bumped to ask the editor to reveal an offset.
65 public private(set) var revealRequest: (offset: Int, id: Int)?
66 public var lastError: String?
67 public var saveMode: SaveMode = .automatic
68 public var autosaveDelay: Duration = .seconds(1)
69 /// Org options for commands the session runs (refile, archive).
70 public var options: EditingOptions = .org
71 /// TODO keywords and priorities for files without their own settings.
72 public var defaults: OrgSettings = .default
73
74 private let fileSystem: FileSystem
75 private let recovery: RecoveryStore
76 @ObservationIgnored private var buffer: DocumentBuffer?
77 @ObservationIgnored var autosave: Task<Void, Never>?
78 @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
79 private var requestID = 0
80
81 public init(
82 fileSystem: FileSystem = CoordinatedFileSystem(),
83 recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))
84 ) {
85 self.fileSystem = fileSystem
86 self.recovery = recovery
87 }
88
89 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
90 /// The caret in the open file.
91 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
92 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
93
94 public func open(_ path: String) {
95 do {
96 guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
97 lastError = "\((path as NSString).lastPathComponent) no longer exists."
98 return
99 }
100 autosave?.cancel()
101 self.path = path
102 buffer = StateBuffer(DocumentState(bytes: bytes, defaults: defaults))
103 conflict = nil
104 revealRequest = nil
105 refresh()
106 } catch {
107 lastError = "Can't open \(path): \(error.localizedDescription)"
108 }
109 }
110
111 /// Makes `buffer`, built from `document`, the live state of the open file.
112 public func attach(_ buffer: DocumentBuffer) {
113 self.buffer = buffer
114 refresh()
115 }
116
117 /// The buffer changed through typing. The outline follows once typing pauses.
118 public func bufferChanged() {
119 refresh(outline: false)
120 outlineUpdate?.cancel()
121 outlineUpdate = Task { [weak self] in
122 try? await Task.sleep(for: .milliseconds(300))
123 guard !Task.isCancelled, let self, let document = self.document else { return }
124 self.outline = Presentation.outline(document.tree)
125 self.editVersion += 1
126 }
127 scheduleAutosave()
128 }
129
130 private func refresh(outline updateOutline: Bool = true) {
131 guard let buffer else { return }
132 let state = buffer.document
133 document = state
134 if isDirty != state.isDirty { isDirty = state.isDirty }
135 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
136 if updateOutline {
137 outline = Presentation.outline(state.tree)
138 editVersion += 1
139 }
140 }
141
142 // MARK: - Saving
143
144 private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
145
146 private func scheduleAutosave() {
147 autosave?.cancel()
148 guard saveMode == .automatic, isDirty, conflict == nil else { return }
149 autosave = Task { [weak self, autosaveDelay] in
150 try? await Task.sleep(for: autosaveDelay)
151 guard !Task.isCancelled else { return }
152 self?.save()
153 }
154 }
155
156 /// Saves now. True when nothing is left unsaved.
157 @discardableResult
158 public func save() -> Bool {
159 autosave?.cancel()
160 guard let buffer, let url, isDirty else { return !isDirty }
161 guard conflict == nil else { return false }
162 do {
163 let outcome = try buffer.update { try saver.save(&$0, to: url) }
164 switch outcome {
165 case .saved, .mergedAndSaved:
166 break
167 case .conflict:
168 conflict = "\(name) changed on disk, and the changes conflict with yours."
169 case .overwroteExternalChange(let change), .changedAfterWrite(let change):
170 if case .conflict = change {
171 conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
172 }
173 }
174 } catch {
175 lastError = "Can't save \(name): \(error.localizedDescription)"
176 }
177 refresh()
178 return !isDirty
179 }
180
181 /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
182 public func keepMine() {
183 guard let buffer, let url else { return }
184 do {
185 try buffer.update { try saver.overwrite(&$0, to: url) }
186 conflict = nil
187 } catch {
188 lastError = "Can't save \(name): \(error.localizedDescription)"
189 }
190 refresh()
191 }
192
193 /// Resolves a conflict by loading the disk version; the buffer goes to recovery.
194 public func useDiskVersion() {
195 guard let buffer, let url else { return }
196 do {
197 guard let bytes = try fileSystem.read(url) else { return }
198 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
199 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
200 conflict = nil
201 } catch {
202 lastError = "Can't read \(name): \(error.localizedDescription)"
203 }
204 refresh()
205 }
206
207 // MARK: - Moving subtrees
208
209 /// The current context of the buffer, as commands see it.
210 private func editContext(now: Date) -> EditContext? {
211 guard let buffer else { return nil }
212 let document = buffer.document
213 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options)
214 }
215
216 /// Moves the subtree at the caret under `target`. For another file, that file is written
217 /// first, through the save path; the subtree leaves this buffer only once it has. Returns a
218 /// message for the echo area.
219 @discardableResult
220 public func refile(to target: RefileTarget, now: Date = Date()) -> String? {
221 guard let buffer, let path, let context = editContext(now: now) else { return nil }
222 if target.path == path {
223 let step = buffer.perform(RefileWithinFile(), now: now, answers: ["refile": target.start.map(String.init) ?? ""])
224 if case .failed(let message) = step { return message }
225 bufferChanged()
226 return "Refiled to \(target.label)"
227 }
228 let url = URL(fileURLWithPath: target.path)
229 do {
230 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." }
231 var state = DocumentState(bytes: bytes, defaults: defaults)
232 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." }
233 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
234 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
235 try state.apply([result.target], baseRevision: state.revision)
236 if case .conflict = try saver.save(&state, to: url) { return "\((target.path as NSString).lastPathComponent) changed on disk; nothing was refiled." }
237 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
238 bufferChanged()
239 return "Refiled to \(target.label)"
240 } catch {
241 return "Can't refile: \(error.localizedDescription)"
242 }
243 }
244
245 /// Archives the subtree at the caret where `org-archive-subtree` would. Returns a message.
246 @discardableResult
247 public func archive(now: Date = Date()) -> String? {
248 guard let buffer, let path, let context = editContext(now: now) else { return nil }
249 let destination: Archive.Destination
250 switch Archive.destination(context, sourcePath: path) {
251 case .success(let value): destination = value
252 case .failure(.message(let message)): return message
253 }
254 do {
255 let url = URL(fileURLWithPath: destination.path)
256 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
257 var state = DocumentState(bytes: existing ?? [], defaults: defaults)
258 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
259 let edits: Archive.Edits
260 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
261 case .success(let value): edits = value
262 case .failure(.message(let message)): return message
263 }
264 if let archiveEdit = edits.archive {
265 try state.apply([archiveEdit], baseRevision: state.revision)
266 if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." }
267 }
268 if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message }
269 bufferChanged()
270 return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)"
271 } catch {
272 return "Can't archive: \(error.localizedDescription)"
273 }
274 }
275
276 // MARK: - Commands from other views
277
278 /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does:
279 /// through the buffer when the file is open, leaving its caret alone; otherwise on the
280 /// file, written back at once through the save path. `expect` is the heading line the
281 /// caller saw at a position; if the text there changed, nothing runs.
282 public func perform(
283 _ command: any OrgCommand, path: String, at offset: Int, expect: (start: Int, line: String)?,
284 answers: [String: String] = [:], now: Date = Date()
285 ) -> CommandStep {
286 let stale = CommandStep.failed("The entry changed; the agenda has been refreshed.")
287 func matches(_ text: String) -> Bool {
288 guard let expect else { return true }
289 let ns = text as NSString
290 guard expect.start + (expect.line as NSString).length <= ns.length else { return false }
291 return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line
292 }
293 if path == self.path, let buffer {
294 guard matches(buffer.document.text) else { return stale }
295 do {
296 let options = self.options
297 let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) }
298 if case .commit = step { bufferChanged() }
299 return step
300 } catch {
301 return .failed("\(error)")
302 }
303 }
304 let url = URL(fileURLWithPath: path)
305 let name = url.lastPathComponent
306 do {
307 guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") }
308 var state = DocumentState(bytes: bytes, defaults: defaults)
309 guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") }
310 guard matches(state.text) else { return stale }
311 let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options)
312 if case .commit = step, state.isDirty, case .conflict = try saver.save(&state, to: url) {
313 return .failed("\(name) changed on disk; nothing was changed.")
314 }
315 return step
316 } catch {
317 return .failed("Can't change \(name): \(error.localizedDescription)")
318 }
319 }
320
321 // MARK: - External work
322
323 /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table
324 /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area.
325 public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String {
326 guard let buffer, let path else { return "No file is open." }
327 let snapshot = buffer.document.text as NSString
328 guard table.upperBound <= snapshot.length else { return "The table moved; try again." }
329 let old = snapshot.substring(with: NSRange(table))
330 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
331 do {
332 let new = try await EmacsRunner.recalculateTable(text: snapshot as String, directory: directory, table: table, point: point, form: form)
333 guard let current = self.buffer, self.path == path else { return "The file was closed before Emacs finished." }
334 let text = current.document.text as NSString
335 guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else {
336 return "The table changed while Emacs recalculated it; nothing was changed."
337 }
338 guard new != old else { return "Recalculated in Emacs" }
339 let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)])
340 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
341 bufferChanged()
342 return "Recalculated in Emacs"
343 } catch {
344 return "\(error)"
345 }
346 }
347
348 /// Runs a src block and writes its result under it, unless the block changed while it
349 /// ran. Returns a message for the echo area.
350 public func runBabel(_ job: BabelJob) async -> String {
351 guard let path, buffer != nil else { return "No file is open." }
352 let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
353 do {
354 let output = try await BabelRunner.run(job, fileDirectory: directory)
355 guard let current = self.buffer, self.path == path else { return "The file was closed before the block finished." }
356 let document = current.document
357 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
358 case .failure(.message(let message)):
359 return message
360 case .success(let edits):
361 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." }
362 let result = EditResult(baseRevision: document.revision, edits: edits)
363 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
364 bufferChanged()
365 return "Code block evaluation complete."
366 }
367 } catch {
368 return "\(error)"
369 }
370 }
371
372 // MARK: - Locations
373
374 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
375 /// are used only if the buffer matches what was indexed; otherwise the heading is found
376 /// again by position and title.
377 public func show(_ location: HeadingLocation) {
378 if path != location.path { open(location.path) }
379 guard let document else { return }
380 let hash = FileRecord.hash(document.mergeBase)
381 let offset: Int
382 if !isDirty, hash == location.contentHash {
383 offset = location.start
384 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
385 offset = outline[location.ordinal].start
386 } else if let entry = outline.first(where: { $0.title == location.title }) {
387 offset = entry.start
388 } else {
389 return
390 }
391 reveal(offset)
392 }
393
394 public func reveal(_ offset: Int) {
395 requestID += 1
396 revealRequest = (offset, requestID)
397 }
398
399 /// Called when the workspace sees the open file change on disk.
400 public func fileChanged(_ path: String) {
401 guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
402 guard bytes != buffer.document.mergeBase else { return }
403 switch buffer.update({ $0.diskChanged(to: bytes) }) {
404 case .conflict:
405 autosave?.cancel()
406 conflict = "\(name) changed on disk, and the changes conflict with yours."
407 case .merged, .reloaded:
408 conflict = nil
409 case .unchanged:
410 break
411 }
412 refresh()
413 }
414}