krz/orgstar

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

Sources/OrgApp/DocumentSession.swift

10c33661279a1fb0e8d3dd51509db9637778769e
orgstar/Sources/OrgApp/DocumentSession.swift history · blame · raw

206 lines · 7909 bytes

  1import Foundation
  2import Observation
  3import OrgCore
  4import OrgDocument
  5import OrgIndex
  6import OrgPresentation
  7
  8public enum SaveMode: String, Sendable, CaseIterable {
  9    /// Save shortly after typing stops.
 10    case automatic
 11    /// Save only on ⌘S.
 12    case explicit
 13}
 14
 15/// The file open in a window: its buffer, outline, saving, and requests for the editor to
 16/// show a location. The view layer attaches the editor as the buffer and follows
 17/// `revealRequest`.
 18@MainActor
 19@Observable
 20public final class DocumentSession {
 21    public private(set) var path: String?
 22    /// A copy of the buffer's state as of its last change. Not observed: it changes on every
 23    /// keystroke; views follow `path`, `isDirty` and `outline`.
 24    @ObservationIgnored public private(set) var document: DocumentState?
 25    public private(set) var outline: [OutlineEntry] = []
 26    public private(set) var isDirty = false
 27    /// The file isn't UTF-8, so it can't be edited or saved.
 28    public private(set) var isReadOnly = false
 29    /// Set while the buffer and the file on disk conflict; says how to resolve it.
 30    public private(set) var conflict: String?
 31    /// Bumped to ask the editor to reveal an offset.
 32    public private(set) var revealRequest: (offset: Int, id: Int)?
 33    public var lastError: String?
 34    public var saveMode: SaveMode = .automatic
 35    public var autosaveDelay: Duration = .seconds(1)
 36
 37    private let fileSystem: FileSystem
 38    private let recovery: RecoveryStore
 39    @ObservationIgnored private var buffer: DocumentBuffer?
 40    @ObservationIgnored var autosave: Task<Void, Never>?
 41    @ObservationIgnored private var outlineUpdate: Task<Void, Never>?
 42    private var requestID = 0
 43
 44    public init(
 45        fileSystem: FileSystem = CoordinatedFileSystem(),
 46        recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))
 47    ) {
 48        self.fileSystem = fileSystem
 49        self.recovery = recovery
 50    }
 51
 52    private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
 53    private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
 54
 55    public func open(_ path: String) {
 56        do {
 57            guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else {
 58                lastError = "\((path as NSString).lastPathComponent) no longer exists."
 59                return
 60            }
 61            autosave?.cancel()
 62            self.path = path
 63            buffer = StateBuffer(DocumentState(bytes: bytes))
 64            conflict = nil
 65            revealRequest = nil
 66            refresh()
 67        } catch {
 68            lastError = "Can't open \(path): \(error.localizedDescription)"
 69        }
 70    }
 71
 72    /// Makes `buffer`, built from `document`, the live state of the open file.
 73    public func attach(_ buffer: DocumentBuffer) {
 74        self.buffer = buffer
 75        refresh()
 76    }
 77
 78    /// The buffer changed through typing. The outline follows once typing pauses.
 79    public func bufferChanged() {
 80        refresh(outline: false)
 81        outlineUpdate?.cancel()
 82        outlineUpdate = Task { [weak self] in
 83            try? await Task.sleep(for: .milliseconds(300))
 84            guard !Task.isCancelled, let self, let document = self.document else { return }
 85            self.outline = Presentation.outline(document.tree)
 86        }
 87        scheduleAutosave()
 88    }
 89
 90    private func refresh(outline updateOutline: Bool = true) {
 91        guard let buffer else { return }
 92        let state = buffer.document
 93        document = state
 94        if isDirty != state.isDirty { isDirty = state.isDirty }
 95        if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
 96        if updateOutline { outline = Presentation.outline(state.tree) }
 97    }
 98
 99    // MARK: - Saving
100
101    private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) }
102
103    private func scheduleAutosave() {
104        autosave?.cancel()
105        guard saveMode == .automatic, isDirty, conflict == nil else { return }
106        autosave = Task { [weak self, autosaveDelay] in
107            try? await Task.sleep(for: autosaveDelay)
108            guard !Task.isCancelled else { return }
109            self?.save()
110        }
111    }
112
113    /// Saves now. True when nothing is left unsaved.
114    @discardableResult
115    public func save() -> Bool {
116        autosave?.cancel()
117        guard let buffer, let url, isDirty else { return !isDirty }
118        guard conflict == nil else { return false }
119        do {
120            let outcome = try buffer.update { try saver.save(&$0, to: url) }
121            switch outcome {
122            case .saved, .mergedAndSaved:
123                break
124            case .conflict:
125                conflict = "\(name) changed on disk, and the changes conflict with yours."
126            case .overwroteExternalChange(let change), .changedAfterWrite(let change):
127                if case .conflict = change {
128                    conflict = "\(name) changed on disk while saving, and the changes conflict with yours."
129                }
130            }
131        } catch {
132            lastError = "Can't save \(name): \(error.localizedDescription)"
133        }
134        refresh()
135        return !isDirty
136    }
137
138    /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery.
139    public func keepMine() {
140        guard let buffer, let url else { return }
141        do {
142            try buffer.update { try saver.overwrite(&$0, to: url) }
143            conflict = nil
144        } catch {
145            lastError = "Can't save \(name): \(error.localizedDescription)"
146        }
147        refresh()
148    }
149
150    /// Resolves a conflict by loading the disk version; the buffer goes to recovery.
151    public func useDiskVersion() {
152        guard let buffer, let url else { return }
153        do {
154            guard let bytes = try fileSystem.read(url) else { return }
155            if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
156            buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
157            conflict = nil
158        } catch {
159            lastError = "Can't read \(name): \(error.localizedDescription)"
160        }
161        refresh()
162    }
163
164    // MARK: - Locations
165
166    /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
167    /// are used only if the buffer matches what was indexed; otherwise the heading is found
168    /// again by position and title.
169    public func show(_ location: HeadingLocation) {
170        if path != location.path { open(location.path) }
171        guard let document else { return }
172        let hash = FileRecord.hash(document.mergeBase)
173        let offset: Int
174        if !isDirty, hash == location.contentHash {
175            offset = location.start
176        } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
177            offset = outline[location.ordinal].start
178        } else if let entry = outline.first(where: { $0.title == location.title }) {
179            offset = entry.start
180        } else {
181            return
182        }
183        reveal(offset)
184    }
185
186    public func reveal(_ offset: Int) {
187        requestID += 1
188        revealRequest = (offset, requestID)
189    }
190
191    /// Called when the workspace sees the open file change on disk.
192    public func fileChanged(_ path: String) {
193        guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return }
194        guard bytes != buffer.document.mergeBase else { return }
195        switch buffer.update({ $0.diskChanged(to: bytes) }) {
196        case .conflict:
197            autosave?.cancel()
198            conflict = "\(name) changed on disk, and the changes conflict with yours."
199        case .merged, .reloaded:
200            conflict = nil
201        case .unchanged:
202            break
203        }
204        refresh()
205    }
206}