Writable app !11
13 files changed, +604 −60
Layout: unified · split
Package.swift +2 −2
| @@ -33,7 +33,7 @@ let package = Package( | ||
| 33 | 33 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
| 34 | 34 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), |
| 35 | 35 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), |
| 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), | |
| 37 | .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"]) | |
| 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit", "OrgApp"]), | |
| 37 | .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"]) | |
| 38 | 38 | ] |
| 39 | 39 | ) |
Sources/OrgApp/DocumentSession.swift +145 −21
| @@ -5,53 +5,173 @@ import OrgDocument | ||
| 5 | 5 | import OrgIndex |
| 6 | 6 | import OrgPresentation |
| 7 | 7 | |
| 8 | /// The file open in a window: its state, its outline, and requests for the editor to show a | |
| 9 | /// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`. | |
| 8 | public 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`. | |
| 10 | 18 | @MainActor |
| 11 | 19 | @Observable |
| 12 | 20 | public final class DocumentSession { |
| 13 | 21 | public private(set) var path: String? |
| 14 | public private(set) var document: DocumentState? | |
| 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? | |
| 15 | 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? | |
| 16 | 31 | /// Bumped to ask the editor to reveal an offset. |
| 17 | 32 | public private(set) var revealRequest: (offset: Int, id: Int)? |
| 18 | /// New bytes from disk for the open file, for the editor to merge or reload. | |
| 19 | public private(set) var diskBytes: (bytes: [UInt8], id: Int)? | |
| 20 | 33 | public var lastError: String? |
| 34 | public var saveMode: SaveMode = .automatic | |
| 35 | public var autosaveDelay: Duration = .seconds(1) | |
| 21 | 36 | |
| 22 | 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>? | |
| 23 | 42 | private var requestID = 0 |
| 24 | 43 | |
| 25 | public init(fileSystem: FileSystem = CoordinatedFileSystem()) { | |
| 44 | public init( | |
| 45 | fileSystem: FileSystem = CoordinatedFileSystem(), | |
| 46 | recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery")) | |
| 47 | ) { | |
| 26 | 48 | self.fileSystem = fileSystem |
| 49 | self.recovery = recovery | |
| 27 | 50 | } |
| 28 | 51 | |
| 52 | private var url: URL? { path.map { URL(fileURLWithPath: $0) } } | |
| 53 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } | |
| 54 | ||
| 29 | 55 | public func open(_ path: String) { |
| 30 | 56 | do { |
| 31 | 57 | guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { |
| 32 | 58 | lastError = "\((path as NSString).lastPathComponent) no longer exists." |
| 33 | 59 | return |
| 34 | 60 | } |
| 35 | let document = DocumentState(bytes: bytes) | |
| 61 | autosave?.cancel() | |
| 36 | 62 | self.path = path |
| 37 | self.document = document | |
| 38 | outline = Presentation.outline(document.tree) | |
| 63 | buffer = StateBuffer(DocumentState(bytes: bytes)) | |
| 64 | conflict = nil | |
| 39 | 65 | revealRequest = nil |
| 40 | diskBytes = nil | |
| 66 | refresh() | |
| 41 | 67 | } catch { |
| 42 | 68 | lastError = "Can't open \(path): \(error.localizedDescription)" |
| 43 | 69 | } |
| 44 | 70 | } |
| 45 | 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 | ||
| 46 | 166 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets |
| 47 | /// are used only if the file is unchanged since it was indexed; otherwise the heading is | |
| 48 | /// found again by position and title. | |
| 167 | /// are used only if the buffer matches what was indexed; otherwise the heading is found | |
| 168 | /// again by position and title. | |
| 49 | 169 | public func show(_ location: HeadingLocation) { |
| 50 | 170 | if path != location.path { open(location.path) } |
| 51 | 171 | guard let document else { return } |
| 52 | 172 | let hash = FileRecord.hash(document.mergeBase) |
| 53 | 173 | let offset: Int |
| 54 | if hash == location.contentHash { | |
| 174 | if !isDirty, hash == location.contentHash { | |
| 55 | 175 | offset = location.start |
| 56 | 176 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { |
| 57 | 177 | offset = outline[location.ordinal].start |
| @@ -70,13 +190,17 @@ public final class DocumentSession { | ||
| 70 | 190 | |
| 71 | 191 | /// Called when the workspace sees the open file change on disk. |
| 72 | 192 | public func fileChanged(_ path: String) { |
| 73 | guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } | |
| 74 | guard bytes != document?.mergeBase else { return } | |
| 75 | var updated = document ?? DocumentState(bytes: bytes) | |
| 76 | _ = updated.diskChanged(to: bytes) | |
| 77 | document = updated | |
| 78 | outline = Presentation.outline(updated.tree) | |
| 79 | requestID += 1 | |
| 80 | diskBytes = (bytes, requestID) | |
| 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() | |
| 81 | 205 | } |
| 82 | 206 | } |
Sources/OrgApp/WorkspaceModel.swift +16
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | import OrgDocument | |
| 3 | 4 | import OrgIndex |
| 4 | 5 | import OrgWorkspace |
| 5 | 6 | |
| @@ -22,6 +23,7 @@ public final class WorkspaceModel { | ||
| 22 | 23 | private var resolvedRoots: [String: URL] = [:] |
| 23 | 24 | @ObservationIgnored private var watcher: AnyObject? |
| 24 | 25 | private var changeVersion = 0 |
| 26 | @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)? | |
| 25 | 27 | |
| 26 | 28 | /// `directory` holds the index and the list of roots; nil keeps both in memory. |
| 27 | 29 | public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { |
| @@ -192,6 +194,20 @@ public final class WorkspaceModel { | ||
| 192 | 194 | (try? index.search(query, overlay: overlay)) ?? [] |
| 193 | 195 | } |
| 194 | 196 | |
| 197 | /// Index rows for an open file's unsaved text, keyed by path, for queries to use in place | |
| 198 | /// of its rows on disk. Empty when the file has no unsaved edits. Cached until the text | |
| 199 | /// changes. | |
| 200 | public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] { | |
| 201 | guard let path, let document, document.isDirty, let bytes = try? document.encodedText(), | |
| 202 | let entry = files.first(where: { $0.path == path }) else { return [:] } | |
| 203 | if let cached = overlayCache, cached.record.path == path, cached.text == document.text { | |
| 204 | return [path: cached.record] | |
| 205 | } | |
| 206 | let record = FileRecord(path: path, root: entry.root, kind: entry.kind, bytes: bytes, mtime: Date().timeIntervalSince1970, settings: settings) | |
| 207 | overlayCache = (document.text, record) | |
| 208 | return [path: record] | |
| 209 | } | |
| 210 | ||
| 195 | 211 | public func quickOpen(_ query: String) -> [FileEntry] { |
| 196 | 212 | FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) |
| 197 | 213 | } |
Sources/OrgDocument/Buffer.swift added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Where an open document's live state is kept: the editor while one shows it, a | |
| 4 | /// `StateBuffer` before that. | |
| 5 | @MainActor | |
| 6 | public protocol DocumentBuffer: AnyObject { | |
| 7 | var document: DocumentState { get } | |
| 8 | /// Applies a change made outside typing (save, disk change, revert) and updates whatever | |
| 9 | /// shows the document. | |
| 10 | func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T | |
| 11 | } | |
| 12 | ||
| 13 | @MainActor | |
| 14 | public final class StateBuffer: DocumentBuffer { | |
| 15 | public private(set) var document: DocumentState | |
| 16 | ||
| 17 | public init(_ document: DocumentState) { | |
| 18 | self.document = document | |
| 19 | } | |
| 20 | ||
| 21 | public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { | |
| 22 | try change(&document) | |
| 23 | } | |
| 24 | } | |
Sources/OrgDocument/Saving.swift +10
| @@ -78,4 +78,14 @@ public struct Saver: Sendable { | ||
| 78 | 78 | } |
| 79 | 79 | throw SaveError.fileKeepsChanging |
| 80 | 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 | } | |
| 81 | 91 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +34 −20
| @@ -153,21 +153,15 @@ public final class OrgEditor: NSObject { | ||
| 153 | 153 | |
| 154 | 154 | // MARK: - Saving |
| 155 | 155 | |
| 156 | /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and | |
| 157 | /// folds are mapped through the change. | |
| 156 | /// Saves through `saver`. A merge that changes the buffer reloads the text view. | |
| 158 | 157 | @discardableResult |
| 159 | 158 | public func save(using saver: Saver, to url: URL) throws -> SaveOutcome { |
| 160 | let before = document.text | |
| 161 | let outcome = try saver.save(&document, to: url) | |
| 162 | if document.text != before { | |
| 163 | let edits = lineEdits(from: before, to: document.text) | |
| 164 | load(document.text) | |
| 165 | let mapped = view.mapped(through: edits) | |
| 166 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | |
| 167 | } | |
| 168 | return outcome | |
| 159 | try update { try saver.save(&$0, to: url) } | |
| 169 | 160 | } |
| 170 | 161 | |
| 162 | /// Called after every change to the document, typed or not. | |
| 163 | public var onChange: (() -> Void)? | |
| 164 | ||
| 171 | 165 | // MARK: - Commands |
| 172 | 166 | |
| 173 | 167 | /// Called with messages commands report, such as why one couldn't run here. |
| @@ -219,15 +213,9 @@ public final class OrgEditor: NSObject { | ||
| 219 | 213 | |
| 220 | 214 | /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept |
| 221 | 215 | /// its headings. |
| 222 | public func diskChanged(to bytes: [UInt8]) { | |
| 223 | let edits: [TextEdit] | |
| 224 | switch document.diskChanged(to: bytes) { | |
| 225 | case .reloaded(let changes), .merged(let changes): edits = changes | |
| 226 | case .unchanged, .conflict: return | |
| 227 | } | |
| 228 | let mapped = view.mapped(through: edits) | |
| 229 | load(document.text) | |
| 230 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | |
| 216 | @discardableResult | |
| 217 | public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange { | |
| 218 | update { $0.diskChanged(to: bytes) } | |
| 231 | 219 | } |
| 232 | 220 | |
| 233 | 221 | // MARK: - Folding |
| @@ -417,6 +405,7 @@ public final class OrgEditor: NSObject { | ||
| 417 | 405 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 418 | 406 | } |
| 419 | 407 | onEditTiming?(clock.now - start) |
| 408 | onChange?() | |
| 420 | 409 | } |
| 421 | 410 | |
| 422 | 411 | /// Columns a wrapped continuation of `line` should start past the line's own start. |
| @@ -475,6 +464,31 @@ public final class OrgEditor: NSObject { | ||
| 475 | 464 | } |
| 476 | 465 | } |
| 477 | 466 | |
| 467 | extension OrgEditor: DocumentBuffer { | |
| 468 | /// Runs `change`; if it changed the text, reloads the view with folds and the selection | |
| 469 | /// mapped through the change. Text-view undo refers to the old text, so it is cleared. | |
| 470 | public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { | |
| 471 | let before = document.text | |
| 472 | let selected = textView.selectedRange() | |
| 473 | let result = try change(&document) | |
| 474 | if document.text != before { | |
| 475 | let edits = lineEdits(from: before, to: document.text) | |
| 476 | var state = view | |
| 477 | state.selection = [selected.location..<NSMaxRange(selected)] | |
| 478 | let mapped = state.mapped(through: edits) | |
| 479 | load(document.text) | |
| 480 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | |
| 481 | let selection = mapped.selection[0] | |
| 482 | setCaret(selection.lowerBound) | |
| 483 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } | |
| 484 | textView.undoManager?.removeAllActions(withTarget: textView.textStorage!) | |
| 485 | textView.undoManager?.removeAllActions(withTarget: textView) | |
| 486 | } | |
| 487 | onChange?() | |
| 488 | return result | |
| 489 | } | |
| 490 | } | |
| 491 | ||
| 478 | 492 | extension OrgEditor: NSTextViewDelegate { |
| 479 | 493 | public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { |
| 480 | 494 | switch selector { |
Sources/Orgstar/ContentView.swift +29 −6
| @@ -4,8 +4,9 @@ import SwiftUI | ||
| 4 | 4 | |
| 5 | 5 | struct ContentView: View { |
| 6 | 6 | let workspace: WorkspaceModel |
| 7 | let session: DocumentSession | |
| 7 | 8 | @Bindable var commands: AppCommands |
| 8 | @State private var session = DocumentSession() | |
| 9 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | |
| 9 | 10 | @State private var selectedFile: String? |
| 10 | 11 | @State private var query = "" |
| 11 | 12 | @FocusState private var searchFocused: Bool |
| @@ -19,7 +20,8 @@ struct ContentView: View { | ||
| 19 | 20 | if query.isEmpty { |
| 20 | 21 | OutlineColumn(session: session) |
| 21 | 22 | } else { |
| 22 | SearchResults(results: workspace.search(query)) { location in | |
| 23 | SearchResults(results: workspace.search(query, overlay: workspace.overlay(path: session.path, document: session.document))) { location in | |
| 24 | guard location.path == session.path || UnsavedChanges.resolve(session) else { return } | |
| 23 | 25 | selectedFile = location.path |
| 24 | 26 | session.show(location) |
| 25 | 27 | } |
| @@ -27,7 +29,7 @@ struct ContentView: View { | ||
| 27 | 29 | } |
| 28 | 30 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 29 | 31 | } detail: { |
| 30 | if session.document != nil { | |
| 32 | if session.path != nil { | |
| 31 | 33 | EditorView(session: session) |
| 32 | 34 | } else { |
| 33 | 35 | ContentUnavailableView( |
| @@ -38,13 +40,34 @@ struct ContentView: View { | ||
| 38 | 40 | } |
| 39 | 41 | } |
| 40 | 42 | .navigationTitle(session.path.map { ($0 as NSString).lastPathComponent } ?? "Orgstar") |
| 43 | .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") | |
| 41 | 44 | .searchable(text: $query, placement: .toolbar, prompt: "Search notes") |
| 42 | 45 | .searchFocused($searchFocused) |
| 43 | 46 | .toolbar { |
| 44 | 47 | if workspace.isIndexing { ProgressView().controlSize(.small) } |
| 48 | if session.conflict != nil { | |
| 49 | Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true } | |
| 50 | .help("The file changed on disk, and the changes conflict with yours") | |
| 51 | } | |
| 45 | 52 | } |
| 46 | .onChange(of: selectedFile) { _, path in | |
| 47 | if let path, path != session.path { session.open(path) } | |
| 53 | .onChange(of: selectedFile) { old, path in | |
| 54 | guard let path, path != session.path else { return } | |
| 55 | if UnsavedChanges.resolve(session) { | |
| 56 | session.open(path) | |
| 57 | } else { | |
| 58 | selectedFile = old | |
| 59 | } | |
| 60 | } | |
| 61 | .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } | |
| 62 | .onChange(of: session.conflict) { _, conflict in | |
| 63 | if conflict != nil { commands.showingConflict = true } | |
| 64 | } | |
| 65 | .alert("Conflicting changes", isPresented: $commands.showingConflict) { | |
| 66 | Button("Keep Mine") { session.keepMine() } | |
| 67 | Button("Use Disk Version") { session.useDiskVersion() } | |
| 68 | Button("Decide Later", role: .cancel) {} | |
| 69 | } message: { | |
| 70 | Text((session.conflict ?? "") + " The version you don't keep is saved in the recovery folder.") | |
| 48 | 71 | } |
| 49 | 72 | .onChange(of: workspace.changedFile?.version) { |
| 50 | 73 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
| @@ -135,7 +158,7 @@ struct OutlineColumn: View { | ||
| 135 | 158 | .buttonStyle(.plain) |
| 136 | 159 | } |
| 137 | 160 | .overlay { |
| 138 | if session.document != nil, session.outline.isEmpty { | |
| 161 | if session.path != nil, session.outline.isEmpty { | |
| 139 | 162 | ContentUnavailableView("No headings", systemImage: "list.bullet.indent") |
| 140 | 163 | } |
| 141 | 164 | } |
Sources/Orgstar/EditorView.swift +6 −8
| @@ -3,8 +3,8 @@ import OrgApp | ||
| 3 | 3 | import OrgEditorAppKit |
| 4 | 4 | import SwiftUI |
| 5 | 5 | |
| 6 | /// The read-only TextKit 2 editor for the session's file. A new file gets a new editor; reveal | |
| 7 | /// requests and changes on disk go to the existing one, so folds survive a reload. | |
| 6 | /// The TextKit 2 editor for the session's file. A new file gets a new editor, which becomes the | |
| 7 | /// session's buffer; reveal requests go to the existing one. | |
| 8 | 8 | struct EditorView: NSViewRepresentable { |
| 9 | 9 | let session: DocumentSession |
| 10 | 10 | |
| @@ -12,7 +12,6 @@ struct EditorView: NSViewRepresentable { | ||
| 12 | 12 | var editor: OrgEditor? |
| 13 | 13 | var path: String? |
| 14 | 14 | var revealID = 0 |
| 15 | var diskID = 0 | |
| 16 | 15 | } |
| 17 | 16 | |
| 18 | 17 | func makeCoordinator() -> Coordinator { Coordinator() } |
| @@ -28,22 +27,21 @@ struct EditorView: NSViewRepresentable { | ||
| 28 | 27 | container.subviews.forEach { $0.removeFromSuperview() } |
| 29 | 28 | coordinator.editor = nil |
| 30 | 29 | if let document = session.document { |
| 31 | let editor = OrgEditor(document: document, frame: container.bounds, editable: false) | |
| 30 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) | |
| 31 | editor.onChange = { [weak session] in session?.bufferChanged() } | |
| 32 | 32 | let scroll = editor.makeScrollView() |
| 33 | 33 | scroll.frame = container.bounds |
| 34 | 34 | scroll.autoresizingMask = [.width, .height] |
| 35 | 35 | container.addSubview(scroll) |
| 36 | 36 | container.window?.makeFirstResponder(editor.textView) |
| 37 | 37 | coordinator.editor = editor |
| 38 | session.attach(editor) | |
| 38 | 39 | } |
| 39 | 40 | } |
| 41 | container.window?.isDocumentEdited = session.isDirty | |
| 40 | 42 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 41 | 43 | coordinator.revealID = request.id |
| 42 | 44 | coordinator.editor?.reveal(request.offset) |
| 43 | 45 | } |
| 44 | if let disk = session.diskBytes, disk.id != coordinator.diskID { | |
| 45 | coordinator.diskID = disk.id | |
| 46 | coordinator.editor?.diskChanged(to: disk.bytes) | |
| 47 | } | |
| 48 | 46 | } |
| 49 | 47 | } |
Sources/Orgstar/OrgstarApp.swift +66 −1
| @@ -1,3 +1,4 @@ | ||
| 1 | import AppKit | |
| 1 | 2 | import OrgApp |
| 2 | 3 | import SwiftUI |
| 3 | 4 | |
| @@ -8,18 +9,56 @@ final class AppCommands { | ||
| 8 | 9 | var showingQuickOpen = false |
| 9 | 10 | var showingFolderPicker = false |
| 10 | 11 | var focusSearch = false |
| 12 | var showingConflict = false | |
| 13 | } | |
| 14 | ||
| 15 | @MainActor | |
| 16 | final class AppDelegate: NSObject, NSApplicationDelegate { | |
| 17 | var session: DocumentSession? | |
| 18 | ||
| 19 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } | |
| 20 | ||
| 21 | func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { | |
| 22 | guard let session else { return .terminateNow } | |
| 23 | return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | /// Unsaved edits when leaving a file or quitting. | |
| 28 | @MainActor | |
| 29 | enum UnsavedChanges { | |
| 30 | /// Whether the open file can be left: saved in automatic mode, or as the user chooses in | |
| 31 | /// explicit mode. A save that fails or conflicts keeps the file open. | |
| 32 | static func resolve(_ session: DocumentSession) -> Bool { | |
| 33 | guard session.isDirty else { return true } | |
| 34 | if session.saveMode == .automatic { return session.save() } | |
| 35 | let alert = NSAlert() | |
| 36 | alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?" | |
| 37 | alert.informativeText = "Your changes will be lost if you don't save them." | |
| 38 | alert.addButton(withTitle: "Save") | |
| 39 | alert.addButton(withTitle: "Don't Save") | |
| 40 | alert.addButton(withTitle: "Cancel") | |
| 41 | switch alert.runModal() { | |
| 42 | case .alertFirstButtonReturn: return session.save() | |
| 43 | case .alertSecondButtonReturn: return true | |
| 44 | default: return false | |
| 45 | } | |
| 46 | } | |
| 11 | 47 | } |
| 12 | 48 | |
| 13 | 49 | @main |
| 14 | 50 | struct OrgstarApp: App { |
| 51 | @NSApplicationDelegateAdaptor private var delegate: AppDelegate | |
| 15 | 52 | @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } |
| 16 | 53 | @State private var commands = AppCommands() |
| 54 | @State private var session = DocumentSession() | |
| 17 | 55 | |
| 18 | 56 | var body: some Scene { |
| 19 | 57 | Window("Orgstar", id: "main") { |
| 20 | 58 | switch workspace { |
| 21 | 59 | case .success(let model): |
| 22 | ContentView(workspace: model, commands: commands) | |
| 60 | ContentView(workspace: model, session: session, commands: commands) | |
| 61 | .onAppear { delegate.session = session } | |
| 23 | 62 | case .failure(let error): |
| 24 | 63 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) |
| 25 | 64 | } |
| @@ -32,10 +71,36 @@ struct OrgstarApp: App { | ||
| 32 | 71 | Button("Quick Open…") { commands.showingQuickOpen = true } |
| 33 | 72 | .keyboardShortcut("p") |
| 34 | 73 | } |
| 74 | CommandGroup(replacing: .saveItem) { | |
| 75 | Button("Save") { | |
| 76 | if !session.save(), session.conflict != nil { commands.showingConflict = true } | |
| 77 | } | |
| 78 | .keyboardShortcut("s") | |
| 79 | .disabled(session.path == nil || session.isReadOnly) | |
| 80 | } | |
| 35 | 81 | CommandGroup(after: .textEditing) { |
| 36 | 82 | Button("Search Notes") { commands.focusSearch = true } |
| 37 | 83 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 38 | 84 | } |
| 39 | 85 | } |
| 86 | Settings { | |
| 87 | SettingsView() | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | struct SettingsView: View { | |
| 93 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | |
| 94 | ||
| 95 | var body: some View { | |
| 96 | Form { | |
| 97 | Picker("Save files:", selection: $saveMode) { | |
| 98 | Text("Automatically, when typing stops").tag(SaveMode.automatic) | |
| 99 | Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) | |
| 100 | } | |
| 101 | .pickerStyle(.radioGroup) | |
| 102 | } | |
| 103 | .padding(20) | |
| 104 | .frame(width: 420) | |
| 40 | 105 | } |
| 41 | 106 | } |
Tests/OrgAppTests/AppTests.swift +98 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import OrgCore |
| 3 | import OrgDocument | |
| 3 | 4 | import OrgIndex |
| 4 | 5 | import Testing |
| 5 | 6 | @testable import OrgApp |
| @@ -90,7 +91,7 @@ struct DocumentSessionTests { | ||
| 90 | 91 | @Test func openShowAndReload() throws { |
| 91 | 92 | let notes = try Folder() |
| 92 | 93 | let file = try notes.write("a.org", "* One\n* Two\n") |
| 93 | let session = DocumentSession() | |
| 94 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 94 | 95 | session.open(file.path) |
| 95 | 96 | #expect(session.outline.map(\.title) == ["One", "Two"]) |
| 96 | 97 | |
| @@ -101,7 +102,6 @@ struct DocumentSessionTests { | ||
| 101 | 102 | try notes.write("a.org", "intro\n* One\n* Two\n") |
| 102 | 103 | session.fileChanged(file.path) |
| 103 | 104 | #expect(session.outline.map(\.start) == [6, 12]) |
| 104 | #expect(session.diskBytes != nil) | |
| 105 | 105 | session.show(location) |
| 106 | 106 | #expect(session.revealRequest?.offset == 12) |
| 107 | 107 | } |
| @@ -113,3 +113,99 @@ struct DocumentSessionTests { | ||
| 113 | 113 | #expect(session.document == nil) |
| 114 | 114 | } |
| 115 | 115 | } |
| 116 | ||
| 117 | @MainActor | |
| 118 | struct SessionSavingTests { | |
| 119 | let notes: Folder | |
| 120 | let file: URL | |
| 121 | let recovery: FileRecoveryStore | |
| 122 | let session: DocumentSession | |
| 123 | ||
| 124 | init() throws { | |
| 125 | notes = try Folder() | |
| 126 | file = try notes.write("a.org", "a\nb\nc\n") | |
| 127 | recovery = FileRecoveryStore(directory: notes.url.appendingPathComponent(".recovery")) | |
| 128 | session = DocumentSession(recovery: recovery) | |
| 129 | session.saveMode = .explicit | |
| 130 | session.open(file.path) | |
| 131 | } | |
| 132 | ||
| 133 | /// Types `text` at `offset`, as the editor would. | |
| 134 | func type(_ text: String, at offset: Int) throws { | |
| 135 | let buffer = StateBuffer(session.document!) | |
| 136 | session.attach(buffer) | |
| 137 | try buffer.update { try $0.apply([TextEdit(range: offset..<offset, replacement: text)], baseRevision: $0.revision) } | |
| 138 | session.bufferChanged() | |
| 139 | } | |
| 140 | ||
| 141 | var disk: String { (try? String(contentsOf: file, encoding: .utf8)) ?? "" } | |
| 142 | ||
| 143 | @Test func saveWritesAndClearsDirty() throws { | |
| 144 | try type("x", at: 0) | |
| 145 | #expect(session.isDirty) | |
| 146 | #expect(session.save()) | |
| 147 | #expect(!session.isDirty) | |
| 148 | #expect(disk == "xa\nb\nc\n") | |
| 149 | } | |
| 150 | ||
| 151 | @Test func automaticModeSavesAfterTheDelay() async throws { | |
| 152 | session.saveMode = .automatic | |
| 153 | session.autosaveDelay = .milliseconds(10) | |
| 154 | try type("x", at: 0) | |
| 155 | #expect(disk == "a\nb\nc\n") | |
| 156 | await session.autosave?.value | |
| 157 | #expect(disk == "xa\nb\nc\n") | |
| 158 | #expect(!session.isDirty) | |
| 159 | } | |
| 160 | ||
| 161 | @Test func explicitModeWaitsForSave() throws { | |
| 162 | try type("x", at: 0) | |
| 163 | #expect(session.autosave == nil) | |
| 164 | } | |
| 165 | ||
| 166 | @Test func externalChangeMergesIntoEdits() throws { | |
| 167 | try type("x", at: 0) | |
| 168 | try notes.write("a.org", "a\nb\nC\n") | |
| 169 | session.fileChanged(file.path) | |
| 170 | #expect(session.document?.text == "xa\nb\nC\n") | |
| 171 | #expect(session.conflict == nil) | |
| 172 | #expect(session.save()) | |
| 173 | #expect(disk == "xa\nb\nC\n") | |
| 174 | } | |
| 175 | ||
| 176 | @Test func conflictKeepMine() throws { | |
| 177 | try type("x", at: 0) | |
| 178 | try notes.write("a.org", "z\nb\nc\n") | |
| 179 | session.fileChanged(file.path) | |
| 180 | #expect(session.conflict != nil) | |
| 181 | #expect(!session.save()) | |
| 182 | #expect(disk == "z\nb\nc\n") | |
| 183 | session.keepMine() | |
| 184 | #expect(session.conflict == nil) | |
| 185 | #expect(disk == "xa\nb\nc\n") | |
| 186 | let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) } | |
| 187 | #expect(kept.contains("z\nb\nc\n")) | |
| 188 | } | |
| 189 | ||
| 190 | @Test func conflictUseDiskVersion() throws { | |
| 191 | try type("x", at: 0) | |
| 192 | try notes.write("a.org", "z\nb\nc\n") | |
| 193 | #expect(!session.save()) | |
| 194 | #expect(session.conflict != nil) | |
| 195 | session.useDiskVersion() | |
| 196 | #expect(session.conflict == nil) | |
| 197 | #expect(!session.isDirty) | |
| 198 | #expect(session.document?.text == "z\nb\nc\n") | |
| 199 | let kept = try recovery.versions(for: file).map { try String(contentsOf: $0, encoding: .utf8) } | |
| 200 | #expect(kept.contains("xa\nb\nc\n")) | |
| 201 | } | |
| 202 | ||
| 203 | @Test func searchSeesUnsavedHeadings() async throws { | |
| 204 | let workspace = try WorkspaceModel(directory: nil) | |
| 205 | try await workspace.addRoot(notes.url) | |
| 206 | try type("* Unsaved heading\n", at: 0) | |
| 207 | let overlay = workspace.overlay(path: session.path, document: session.document) | |
| 208 | #expect(workspace.search("unsaved").isEmpty) | |
| 209 | #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"]) | |
| 210 | } | |
| 211 | } | |
Tests/OrgDocumentTests/SaveTests.swift +10
| @@ -103,6 +103,16 @@ struct SaveTests { | ||
| 103 | 103 | #expect(doc.text == "A\nb\nc\n") |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | @Test func overwriteKeepsTheReplacedVersion() throws { | |
| 107 | let files = FaultyFileSystem("Z\nb\nc\n") | |
| 108 | let recovery = MemoryRecovery() | |
| 109 | var doc = try editedState() | |
| 110 | try Saver(fileSystem: files, recovery: recovery).overwrite(&doc, to: url) | |
| 111 | #expect(files.text == "A\nb\nc\n") | |
| 112 | #expect(!doc.isDirty) | |
| 113 | #expect(recovery.contains("Z\nb\nc\n")) | |
| 114 | } | |
| 115 | ||
| 106 | 116 | @Test func changedBetweenReadAndCheckRetries() throws { |
| 107 | 117 | let files = FaultyFileSystem("a\nb\nc\n") |
| 108 | 118 | files.beforeRead[2] = Array("a\nb\nC\n".utf8) |
Tests/OrgEditorAppKitTests/EditorTests.swift +48
| @@ -2,6 +2,7 @@ import AppKit | ||
| 2 | 2 | import OrgCore |
| 3 | 3 | import OrgDocument |
| 4 | 4 | import OrgPresentation |
| 5 | @testable import OrgApp | |
| 5 | 6 | import Testing |
| 6 | 7 | @testable import OrgEditorAppKit |
| 7 | 8 | |
| @@ -229,6 +230,53 @@ struct EditingTests { | ||
| 229 | 230 | } |
| 230 | 231 | } |
| 231 | 232 | |
| 233 | @MainActor | |
| 234 | struct BufferTests { | |
| 235 | @Test func updateMapsCaretAndFoldsAndClearsUndo() { | |
| 236 | let h = Harness(outline) | |
| 237 | var changes = 0 | |
| 238 | h.editor.onChange = { changes += 1 } | |
| 239 | h.caret(at: 1) | |
| 240 | h.textView.insertText("x", replacementRange: NSRange(location: 4, length: 0)) | |
| 241 | #expect(changes == 1) | |
| 242 | RunLoop.current.run(until: Date()) | |
| 243 | #expect(h.textView.undoManager?.canUndo == true) | |
| 244 | h.editor.setFolds(FoldState(folds: [h.offset(of: "** b")])) | |
| 245 | h.caret(at: h.offset(of: "* c") + 2) | |
| 246 | h.editor.update { $0 = DocumentState(bytes: Array(("new\n" + outline).utf8)) } | |
| 247 | #expect(changes == 2) | |
| 248 | #expect(h.string == "new\n" + outline) | |
| 249 | #expect(h.caret == h.offset(of: "* c") + 2) | |
| 250 | #expect(h.editor.foldState.folds == [h.offset(of: "** b")]) | |
| 251 | #expect(h.textView.undoManager?.canUndo != true) | |
| 252 | h.checkInSync() | |
| 253 | } | |
| 254 | ||
| 255 | /// The wiring the app uses: the editor is the session's buffer and reports typing. | |
| 256 | @Test func sessionSavesWhatIsTyped() async throws { | |
| 257 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)") | |
| 258 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | |
| 259 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 260 | let file = folder.appendingPathComponent("a.org") | |
| 261 | try Data(outline.utf8).write(to: file) | |
| 262 | let session = DocumentSession(recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r"))) | |
| 263 | session.autosaveDelay = .milliseconds(10) | |
| 264 | session.open(file.path) | |
| 265 | let h = Harness(try #require(session.document)) | |
| 266 | h.editor.onChange = { [weak session] in session?.bufferChanged() } | |
| 267 | session.attach(h.editor) | |
| 268 | h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0)) | |
| 269 | #expect(session.isDirty) | |
| 270 | await session.autosave?.value | |
| 271 | #expect(!session.isDirty) | |
| 272 | #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline) | |
| 273 | ||
| 274 | try Data(("x" + outline + "end\n").utf8).write(to: file) | |
| 275 | session.fileChanged(file.path) | |
| 276 | #expect(h.string == "x" + outline + "end\n") | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 232 | 280 | @MainActor |
| 233 | 281 | struct RestyleTests { |
| 234 | 282 | func indent(_ h: Harness, at line: String) -> CGFloat? { |
docs/plans/2026-10-05-writable-app.md added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | # Writable App Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Editing on in the Mac app: typing and undo, ⌘S through the merge-safe save path, a save-mode setting (automatic after an idle delay, or explicit), dirty state, external changes merged into edited buffers with a conflict choice, and unsaved text visible to search. | |
| 6 | ||
| 7 | **Architecture:** The editor owns the live `DocumentState`; the session reaches it through a `DocumentBuffer` protocol in `OrgDocument` (`document` plus `update(_:)`, which applies a change made outside typing and refreshes the view). Save, disk change, keep-mine and use-disk are all `update` calls on the buffer, so the editor maps folds and the caret through them the same way. `DocumentSession` keeps a copy of the buffer's state for SwiftUI (dirty flag, outline), runs autosave, and holds conflict state. Before an editor exists, and in tests, a `StateBuffer` holds the state. Search overlays the open file's unsaved text through `WorkspaceModel.overlay`, cached until the text changes. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, SwiftUI, AppKit, TextKit 2, Swift Testing. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Saving" and "Index schema" (overlay). | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Every write goes through `Saver`; every version a write or a conflict choice displaces goes to the recovery folder (`<data dir>/Recovery`, last 20 per file). | |
| 16 | - Files that are not valid UTF-8 stay read-only. | |
| 17 | - A conflict never writes; autosave pauses while one is open. | |
| 18 | - `OrgApp` imports no AppKit or SwiftUI. | |
| 19 | ||
| 20 | ## Defaults chosen (user may change) | |
| 21 | ||
| 22 | - Save mode defaults to automatic, 1 s after the last edit. The delay is fixed. | |
| 23 | - Conflicts: an alert with Keep Mine, Use Disk Version, Decide Later. No diff view yet. | |
| 24 | - One document per window. Switching files with unsaved edits saves (automatic) or asks Save / Don't Save / Cancel (explicit). Closing the window quits the app, through the same check. | |
| 25 | ||
| 26 | ## File structure | |
| 27 | ||
| 28 | | File | Responsibility | | |
| 29 | | --- | --- | | |
| 30 | | `Sources/OrgDocument/Buffer.swift` | `DocumentBuffer`, `StateBuffer` | | |
| 31 | | `Sources/OrgDocument/Saving.swift` | `Saver.overwrite(_:to:)` | | |
| 32 | | `Sources/OrgEditorAppKit/OrgEditor.swift` | `DocumentBuffer` conformance: `update(_:)` maps folds and caret and clears text-view undo when the text is replaced; `onChange` | | |
| 33 | | `Sources/OrgApp/DocumentSession.swift` | Buffer, dirty flag, save, autosave, conflict choices, `SaveMode` | | |
| 34 | | `Sources/OrgApp/WorkspaceModel.swift` | `overlay(path:document:)` | | |
| 35 | | `Sources/Orgstar/*.swift` | Editable editor, ⌘S, Settings, confirm on switch and quit, conflict alert, edited dot | | |
| 36 | ||
| 37 | --- | |
| 38 | ||
| 39 | ### Task 1: Buffers | |
| 40 | ||
| 41 | **Files:** `Sources/OrgDocument/Buffer.swift`, `Sources/OrgDocument/Saving.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift` | |
| 42 | ||
| 43 | **Produces:** | |
| 44 | ||
| 45 | ```swift | |
| 46 | @MainActor | |
| 47 | public protocol DocumentBuffer: AnyObject { | |
| 48 | var document: DocumentState { get } | |
| 49 | /// Applies a change made outside typing and updates whatever shows the document. | |
| 50 | func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T | |
| 51 | } | |
| 52 | ||
| 53 | @MainActor | |
| 54 | public final class StateBuffer: DocumentBuffer { public init(_ document: DocumentState) } | |
| 55 | ||
| 56 | extension Saver { | |
| 57 | /// Writes the buffer over whatever is on disk; the replaced version goes to recovery. | |
| 58 | public func overwrite(_ state: inout DocumentState, to url: URL) throws | |
| 59 | } | |
| 60 | ||
| 61 | extension OrgEditor: DocumentBuffer // plus `public var onChange: (() -> Void)?` | |
| 62 | ``` | |
| 63 | ||
| 64 | `OrgEditor.update` records the text and selection, runs the change, and if the text changed: computes `lineEdits(from:to:)`, reloads the storage, maps folds and the selection through the edits, and removes the text view's undo actions (they refer to the old text). `save(using:to:)` and `diskChanged(to:)` become `update` calls; `diskChanged` returns the `ExternalChange`. | |
| 65 | ||
| 66 | - [ ] Tests: `overwrite` writes ours and keeps the replaced version; editor `update` replacing text keeps the caret on the same line content, keeps folds, and leaves nothing to undo; typing calls `onChange`. | |
| 67 | - [ ] Implement; `swift test` green. | |
| 68 | - [ ] Commit "Add document buffers". | |
| 69 | ||
| 70 | ### Task 2: Session saving | |
| 71 | ||
| 72 | **Files:** `Sources/OrgApp/DocumentSession.swift`, `Sources/OrgApp/WorkspaceModel.swift`, `Tests/OrgAppTests/AppTests.swift` | |
| 73 | ||
| 74 | **Produces:** | |
| 75 | ||
| 76 | ```swift | |
| 77 | public enum SaveMode: String, Sendable, CaseIterable { case automatic, explicit } | |
| 78 | ||
| 79 | extension DocumentSession { | |
| 80 | public init(fileSystem: FileSystem = CoordinatedFileSystem(), recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery"))) | |
| 81 | public var saveMode: SaveMode | |
| 82 | public private(set) var isDirty: Bool | |
| 83 | /// Set while the buffer and the disk conflict; the message says how. | |
| 84 | public private(set) var conflict: String? | |
| 85 | public func attach(_ buffer: DocumentBuffer) | |
| 86 | /// The buffer changed through typing. | |
| 87 | public func bufferChanged() | |
| 88 | /// Saves now. True when nothing is left unsaved. | |
| 89 | @discardableResult public func save() -> Bool | |
| 90 | public func keepMine() | |
| 91 | public func useDiskVersion() | |
| 92 | } | |
| 93 | ||
| 94 | extension WorkspaceModel { | |
| 95 | /// Index rows for the open file's unsaved text, keyed by path; empty when it has none. | |
| 96 | public func overlay(path: String?, document: DocumentState?) -> [String: FileRecord] | |
| 97 | } | |
| 98 | ``` | |
| 99 | ||
| 100 | `fileChanged` calls `buffer.update { $0.diskChanged(to:) }` and sets `conflict` on a conflict; `diskBytes` goes away. `show(_:)` uses indexed offsets only when the buffer is clean. | |
| 101 | ||
| 102 | - [ ] Tests: edit then `save` writes and clears dirty; automatic mode saves after the delay, explicit does not; an external change merges into an edited buffer; a conflicting change sets `conflict`, blocks saving, and `keepMine` writes ours with theirs in recovery while `useDiskVersion` loads theirs with ours in recovery; search with the overlay finds an unsaved heading. | |
| 103 | - [ ] Implement; `swift test` green. | |
| 104 | - [ ] Commit "Save from the session". | |
| 105 | ||
| 106 | ### Task 3: The app | |
| 107 | ||
| 108 | **Files:** `Sources/Orgstar/*.swift` | |
| 109 | ||
| 110 | - Session moves to app state so the app delegate can check it on quit; closing the last window quits. | |
| 111 | - Editor is editable when the file is; it attaches to the session and calls `bufferChanged` on edits; the window shows the edited dot. | |
| 112 | - File ▸ Save (⌘S); Settings (⌘,) with the save mode in `@AppStorage("saveMode")`. | |
| 113 | - Switching files and quitting with unsaved edits: save in automatic mode, ask in explicit mode; a failed save cancels. | |
| 114 | - Conflict alert with the three choices. | |
| 115 | - [ ] Verify in the built app with a scratch data dir: type, ⌘S, undo, external edit merges, conflicting external edit shows the alert, search finds unsaved text. | |
| 116 | - [ ] Commit "Edit and save in the app". | |