Sources/OrgApp/DocumentSession.swift
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}