Commit 923f63b7fc
Verified · cmc
Layout: unified · split
Package.swift +1 −1
| @@ -34,6 +34,6 @@ let package = Package( | ||
| 34 | 34 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), |
| 35 | 35 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), |
| 36 | 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), |
| 37 | .testTarget(name: "OrgAppTests", dependencies: ["OrgApp"]) | |
| 37 | .testTarget(name: "OrgAppTests", dependencies: ["OrgApp", "OrgDocument"]) | |
| 38 | 38 | ] |
| 39 | 39 | ) |
Sources/OrgApp/DocumentSession.swift +142 −21
| @@ -5,53 +5,170 @@ 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 | /// Set while the buffer and the file on disk conflict; says how to resolve it. | |
| 28 | public private(set) var conflict: String? | |
| 16 | 29 | /// Bumped to ask the editor to reveal an offset. |
| 17 | 30 | 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 | 31 | public var lastError: String? |
| 32 | public var saveMode: SaveMode = .automatic | |
| 33 | public var autosaveDelay: Duration = .seconds(1) | |
| 21 | 34 | |
| 22 | 35 | private let fileSystem: FileSystem |
| 36 | private let recovery: RecoveryStore | |
| 37 | @ObservationIgnored private var buffer: DocumentBuffer? | |
| 38 | @ObservationIgnored var autosave: Task<Void, Never>? | |
| 39 | @ObservationIgnored private var outlineUpdate: Task<Void, Never>? | |
| 23 | 40 | private var requestID = 0 |
| 24 | 41 | |
| 25 | public init(fileSystem: FileSystem = CoordinatedFileSystem()) { | |
| 42 | public init( | |
| 43 | fileSystem: FileSystem = CoordinatedFileSystem(), | |
| 44 | recovery: RecoveryStore = FileRecoveryStore(directory: WorkspaceModel.defaultDirectory.appendingPathComponent("Recovery")) | |
| 45 | ) { | |
| 26 | 46 | self.fileSystem = fileSystem |
| 47 | self.recovery = recovery | |
| 27 | 48 | } |
| 28 | 49 | |
| 50 | private var url: URL? { path.map { URL(fileURLWithPath: $0) } } | |
| 51 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } | |
| 52 | ||
| 29 | 53 | public func open(_ path: String) { |
| 30 | 54 | do { |
| 31 | 55 | guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { |
| 32 | 56 | lastError = "\((path as NSString).lastPathComponent) no longer exists." |
| 33 | 57 | return |
| 34 | 58 | } |
| 35 | let document = DocumentState(bytes: bytes) | |
| 59 | autosave?.cancel() | |
| 36 | 60 | self.path = path |
| 37 | self.document = document | |
| 38 | outline = Presentation.outline(document.tree) | |
| 61 | buffer = StateBuffer(DocumentState(bytes: bytes)) | |
| 62 | conflict = nil | |
| 39 | 63 | revealRequest = nil |
| 40 | diskBytes = nil | |
| 64 | refresh() | |
| 41 | 65 | } catch { |
| 42 | 66 | lastError = "Can't open \(path): \(error.localizedDescription)" |
| 43 | 67 | } |
| 44 | 68 | } |
| 45 | 69 | |
| 70 | /// Makes `buffer`, built from `document`, the live state of the open file. | |
| 71 | public func attach(_ buffer: DocumentBuffer) { | |
| 72 | self.buffer = buffer | |
| 73 | refresh() | |
| 74 | } | |
| 75 | ||
| 76 | /// The buffer changed through typing. The outline follows once typing pauses. | |
| 77 | public func bufferChanged() { | |
| 78 | refresh(outline: false) | |
| 79 | outlineUpdate?.cancel() | |
| 80 | outlineUpdate = Task { [weak self] in | |
| 81 | try? await Task.sleep(for: .milliseconds(300)) | |
| 82 | guard !Task.isCancelled, let self, let document = self.document else { return } | |
| 83 | self.outline = Presentation.outline(document.tree) | |
| 84 | } | |
| 85 | scheduleAutosave() | |
| 86 | } | |
| 87 | ||
| 88 | private func refresh(outline updateOutline: Bool = true) { | |
| 89 | guard let buffer else { return } | |
| 90 | let state = buffer.document | |
| 91 | document = state | |
| 92 | if isDirty != state.isDirty { isDirty = state.isDirty } | |
| 93 | if updateOutline { outline = Presentation.outline(state.tree) } | |
| 94 | } | |
| 95 | ||
| 96 | // MARK: - Saving | |
| 97 | ||
| 98 | private var saver: Saver { Saver(fileSystem: fileSystem, recovery: recovery) } | |
| 99 | ||
| 100 | private func scheduleAutosave() { | |
| 101 | autosave?.cancel() | |
| 102 | guard saveMode == .automatic, isDirty, conflict == nil else { return } | |
| 103 | autosave = Task { [weak self, autosaveDelay] in | |
| 104 | try? await Task.sleep(for: autosaveDelay) | |
| 105 | guard !Task.isCancelled else { return } | |
| 106 | self?.save() | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | /// Saves now. True when nothing is left unsaved. | |
| 111 | @discardableResult | |
| 112 | public func save() -> Bool { | |
| 113 | autosave?.cancel() | |
| 114 | guard let buffer, let url, isDirty else { return !isDirty } | |
| 115 | guard conflict == nil else { return false } | |
| 116 | do { | |
| 117 | let outcome = try buffer.update { try saver.save(&$0, to: url) } | |
| 118 | switch outcome { | |
| 119 | case .saved, .mergedAndSaved: | |
| 120 | break | |
| 121 | case .conflict: | |
| 122 | conflict = "\(name) changed on disk, and the changes conflict with yours." | |
| 123 | case .overwroteExternalChange(let change), .changedAfterWrite(let change): | |
| 124 | if case .conflict = change { | |
| 125 | conflict = "\(name) changed on disk while saving, and the changes conflict with yours." | |
| 126 | } | |
| 127 | } | |
| 128 | } catch { | |
| 129 | lastError = "Can't save \(name): \(error.localizedDescription)" | |
| 130 | } | |
| 131 | refresh() | |
| 132 | return !isDirty | |
| 133 | } | |
| 134 | ||
| 135 | /// Resolves a conflict by writing the buffer over the disk version, which goes to recovery. | |
| 136 | public func keepMine() { | |
| 137 | guard let buffer, let url else { return } | |
| 138 | do { | |
| 139 | try buffer.update { try saver.overwrite(&$0, to: url) } | |
| 140 | conflict = nil | |
| 141 | } catch { | |
| 142 | lastError = "Can't save \(name): \(error.localizedDescription)" | |
| 143 | } | |
| 144 | refresh() | |
| 145 | } | |
| 146 | ||
| 147 | /// Resolves a conflict by loading the disk version; the buffer goes to recovery. | |
| 148 | public func useDiskVersion() { | |
| 149 | guard let buffer, let url else { return } | |
| 150 | do { | |
| 151 | guard let bytes = try fileSystem.read(url) else { return } | |
| 152 | if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") } | |
| 153 | buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) } | |
| 154 | conflict = nil | |
| 155 | } catch { | |
| 156 | lastError = "Can't read \(name): \(error.localizedDescription)" | |
| 157 | } | |
| 158 | refresh() | |
| 159 | } | |
| 160 | ||
| 161 | // MARK: - Locations | |
| 162 | ||
| 46 | 163 | /// 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. | |
| 164 | /// are used only if the buffer matches what was indexed; otherwise the heading is found | |
| 165 | /// again by position and title. | |
| 49 | 166 | public func show(_ location: HeadingLocation) { |
| 50 | 167 | if path != location.path { open(location.path) } |
| 51 | 168 | guard let document else { return } |
| 52 | 169 | let hash = FileRecord.hash(document.mergeBase) |
| 53 | 170 | let offset: Int |
| 54 | if hash == location.contentHash { | |
| 171 | if !isDirty, hash == location.contentHash { | |
| 55 | 172 | offset = location.start |
| 56 | 173 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { |
| 57 | 174 | offset = outline[location.ordinal].start |
| @@ -70,13 +187,17 @@ public final class DocumentSession { | ||
| 70 | 187 | |
| 71 | 188 | /// Called when the workspace sees the open file change on disk. |
| 72 | 189 | 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) | |
| 190 | guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } | |
| 191 | guard bytes != buffer.document.mergeBase else { return } | |
| 192 | switch buffer.update({ $0.diskChanged(to: bytes) }) { | |
| 193 | case .conflict: | |
| 194 | autosave?.cancel() | |
| 195 | conflict = "\(name) changed on disk, and the changes conflict with yours." | |
| 196 | case .merged, .reloaded: | |
| 197 | conflict = nil | |
| 198 | case .unchanged: | |
| 199 | break | |
| 200 | } | |
| 201 | refresh() | |
| 81 | 202 | } |
| 82 | 203 | } |
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/Orgstar/ContentView.swift +2 −2
| @@ -27,7 +27,7 @@ struct ContentView: View { | ||
| 27 | 27 | } |
| 28 | 28 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 29 | 29 | } detail: { |
| 30 | if session.document != nil { | |
| 30 | if session.path != nil { | |
| 31 | 31 | EditorView(session: session) |
| 32 | 32 | } else { |
| 33 | 33 | ContentUnavailableView( |
| @@ -135,7 +135,7 @@ struct OutlineColumn: View { | ||
| 135 | 135 | .buttonStyle(.plain) |
| 136 | 136 | } |
| 137 | 137 | .overlay { |
| 138 | if session.document != nil, session.outline.isEmpty { | |
| 138 | if session.path != nil, session.outline.isEmpty { | |
| 139 | 139 | ContentUnavailableView("No headings", systemImage: "list.bullet.indent") |
| 140 | 140 | } |
| 141 | 141 | } |
Sources/Orgstar/EditorView.swift +1 −5
| @@ -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() } |
| @@ -35,15 +34,12 @@ struct EditorView: NSViewRepresentable { | ||
| 35 | 34 | container.addSubview(scroll) |
| 36 | 35 | container.window?.makeFirstResponder(editor.textView) |
| 37 | 36 | coordinator.editor = editor |
| 37 | session.attach(editor) | |
| 38 | 38 | } |
| 39 | 39 | } |
| 40 | 40 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 41 | 41 | coordinator.revealID = request.id |
| 42 | 42 | coordinator.editor?.reveal(request.offset) |
| 43 | 43 | } |
| 44 | if let disk = session.diskBytes, disk.id != coordinator.diskID { | |
| 45 | coordinator.diskID = disk.id | |
| 46 | coordinator.editor?.diskChanged(to: disk.bytes) | |
| 47 | } | |
| 48 | 44 | } |
| 49 | 45 | } |
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 | } | |