krz/orgstar

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

Sources/OrgApp/DocumentSession.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgApp/DocumentSession.swift history · blame · raw

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}