krz/orgstar

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

Sources/OrgDocument/Saving.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgDocument/Saving.swift history · blame · raw

91 lines · 4014 bytes

 1import Foundation
 2import OrgCore
 3
 4public protocol FileSystem: Sendable {
 5    /// The file's bytes, or nil if it doesn't exist.
 6    func read(_ url: URL) throws -> [UInt8]?
 7    /// Replaces the file through a temporary file in the same folder. Returns the bytes that
 8    /// were replaced, read from the replaced file itself, or nil if there was none.
 9    func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]?
10}
11
12public protocol RecoveryStore: Sendable {
13    func keep(_ bytes: [UInt8], for url: URL, label: String) throws
14}
15
16public enum SaveOutcome: Sendable, Equatable {
17    case saved
18    /// The file had changed since it was read; the change merged cleanly into the buffer and
19    /// the merge was written.
20    case mergedAndSaved([TextEdit])
21    /// The file had changed and the change conflicts. Nothing was written; the buffer is
22    /// unchanged.
23    case conflict([MergeConflict])
24    /// Another writer replaced the file between our last check and our write. Ours is on disk;
25    /// theirs is in recovery and was merged into the buffer where possible.
26    case overwroteExternalChange(DocumentState.ExternalChange)
27    /// Another writer changed the file right after our write. Theirs is on disk; ours is in
28    /// recovery.
29    case changedAfterWrite(DocumentState.ExternalChange)
30}
31
32public enum SaveError: Error, Equatable {
33    case fileKeepsChanging
34}
35
36/// The save sequence from the design: read, merge if the file moved, check again, replace,
37/// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it
38/// narrows the window and makes sure every version it displaces lands in recovery.
39public struct Saver: Sendable {
40    public let fileSystem: FileSystem
41    public let recovery: RecoveryStore
42    public var maxAttempts = 3
43
44    public init(fileSystem: FileSystem, recovery: RecoveryStore) {
45        self.fileSystem = fileSystem
46        self.recovery = recovery
47    }
48
49    public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome {
50        guard state.isEditable else { throw DocumentState.EditError.readOnly }
51        for _ in 0..<maxAttempts {
52            let disk = try fileSystem.read(url)
53            var merged: [TextEdit]?
54            if let disk, disk != state.mergeBase {
55                try recovery.keep(disk, for: url, label: "external")
56                try recovery.keep(state.encodedText(), for: url, label: "local")
57                switch state.diskChanged(to: disk) {
58                case .conflict(let conflicts): return .conflict(conflicts)
59                case .merged(let edits), .reloaded(let edits): merged = edits
60                case .unchanged: break
61                }
62            }
63            let bytes = try state.encodedText()
64            guard try fileSystem.read(url) == disk else { continue }
65
66            let replaced = try fileSystem.replace(url, with: bytes)
67            state.didWrite(bytes)
68            if replaced != disk, let replaced {
69                try recovery.keep(replaced, for: url, label: "external")
70                return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? []))
71            }
72
73            if let after = try fileSystem.read(url), after != bytes {
74                try recovery.keep(bytes, for: url, label: "local")
75                return .changedAfterWrite(state.diskChanged(to: after))
76            }
77            return merged.map { .mergedAndSaved($0) } ?? .saved
78        }
79        throw SaveError.fileKeepsChanging
80    }
81
82    /// Writes the buffer over whatever is on disk, for keeping our version over a conflicting
83    /// one. The replaced version goes to recovery unless it is the merge base.
84    public func overwrite(_ state: inout DocumentState, to url: URL) throws {
85        let bytes = try state.encodedText()
86        if let replaced = try fileSystem.replace(url, with: bytes), replaced != state.mergeBase {
87            try recovery.keep(replaced, for: url, label: "external")
88        }
89        state.didWrite(bytes)
90    }
91}