Commit 923f63b7fc
Verified · cmc
Layout: unified · split
Package.swift +1 −1
| @@ -34,6 +34,6 @@ let package = Package( | |||
| 34 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), | 34 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]), |
| 35 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), | 35 | .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]), |
| 36 | .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"]), | 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 | import OrgIndex | 5 | import OrgIndex |
| 6 | import OrgPresentation | 6 | import OrgPresentation |
| 7 | 7 | ||
| 8 | /// The file open in a window: its state, its outline, and requests for the editor to show a | 8 | public enum SaveMode: String, Sendable, CaseIterable { |
| 9 | /// location. The view layer owns the text view and follows `revealRequest` and `diskBytes`. | 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 | @MainActor | 18 | @MainActor |
| 11 | @Observable | 19 | @Observable |
| 12 | public final class DocumentSession { | 20 | public final class DocumentSession { |
| 13 | public private(set) var path: String? | 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 | public private(set) var outline: [OutlineEntry] = [] | 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 | /// Bumped to ask the editor to reveal an offset. | 29 | /// Bumped to ask the editor to reveal an offset. |
| 17 | public private(set) var revealRequest: (offset: Int, id: Int)? | 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 | public var lastError: String? | 31 | public var lastError: String? |
| 32 | public var saveMode: SaveMode = .automatic | ||
| 33 | public var autosaveDelay: Duration = .seconds(1) | ||
| 21 | 34 | ||
| 22 | private let fileSystem: FileSystem | 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 | private var requestID = 0 | 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 | self.fileSystem = fileSystem | 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 | public func open(_ path: String) { | 53 | public func open(_ path: String) { |
| 30 | do { | 54 | do { |
| 31 | guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { | 55 | guard let bytes = try fileSystem.read(URL(fileURLWithPath: path)) else { |
| 32 | lastError = "\((path as NSString).lastPathComponent) no longer exists." | 56 | lastError = "\((path as NSString).lastPathComponent) no longer exists." |
| 33 | return | 57 | return |
| 34 | } | 58 | } |
| 35 | let document = DocumentState(bytes: bytes) | 59 | autosave?.cancel() |
| 36 | self.path = path | 60 | self.path = path |
| 37 | self.document = document | 61 | buffer = StateBuffer(DocumentState(bytes: bytes)) |
| 38 | outline = Presentation.outline(document.tree) | 62 | conflict = nil |
| 39 | revealRequest = nil | 63 | revealRequest = nil |
| 40 | diskBytes = nil | 64 | refresh() |
| 41 | } catch { | 65 | } catch { |
| 42 | lastError = "Can't open \(path): \(error.localizedDescription)" | 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 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets | 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 | 164 | /// are used only if the buffer matches what was indexed; otherwise the heading is found |
| 48 | /// found again by position and title. | 165 | /// again by position and title. |
| 49 | public func show(_ location: HeadingLocation) { | 166 | public func show(_ location: HeadingLocation) { |
| 50 | if path != location.path { open(location.path) } | 167 | if path != location.path { open(location.path) } |
| 51 | guard let document else { return } | 168 | guard let document else { return } |
| 52 | let hash = FileRecord.hash(document.mergeBase) | 169 | let hash = FileRecord.hash(document.mergeBase) |
| 53 | let offset: Int | 170 | let offset: Int |
| 54 | if hash == location.contentHash { | 171 | if !isDirty, hash == location.contentHash { |
| 55 | offset = location.start | 172 | offset = location.start |
| 56 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { | 173 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { |
| 57 | offset = outline[location.ordinal].start | 174 | offset = outline[location.ordinal].start |
| @@ -70,13 +187,17 @@ public final class DocumentSession { | |||
| 70 | 187 | ||
| 71 | /// Called when the workspace sees the open file change on disk. | 188 | /// Called when the workspace sees the open file change on disk. |
| 72 | public func fileChanged(_ path: String) { | 189 | public func fileChanged(_ path: String) { |
| 73 | guard path == self.path, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } | 190 | guard path == self.path, let buffer, let bytes = try? fileSystem.read(URL(fileURLWithPath: path)) else { return } |
| 74 | guard bytes != document?.mergeBase else { return } | 191 | guard bytes != buffer.document.mergeBase else { return } |
| 75 | var updated = document ?? DocumentState(bytes: bytes) | 192 | switch buffer.update({ $0.diskChanged(to: bytes) }) { |
| 76 | _ = updated.diskChanged(to: bytes) | 193 | case .conflict: |
| 77 | document = updated | 194 | autosave?.cancel() |
| 78 | outline = Presentation.outline(updated.tree) | 195 | conflict = "\(name) changed on disk, and the changes conflict with yours." |
| 79 | requestID += 1 | 196 | case .merged, .reloaded: |
| 80 | diskBytes = (bytes, requestID) | 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 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | import OrgDocument | ||
| 3 | import OrgIndex | 4 | import OrgIndex |
| 4 | import OrgWorkspace | 5 | import OrgWorkspace |
| 5 | 6 | ||
| @@ -22,6 +23,7 @@ public final class WorkspaceModel { | |||
| 22 | private var resolvedRoots: [String: URL] = [:] | 23 | private var resolvedRoots: [String: URL] = [:] |
| 23 | @ObservationIgnored private var watcher: AnyObject? | 24 | @ObservationIgnored private var watcher: AnyObject? |
| 24 | private var changeVersion = 0 | 25 | private var changeVersion = 0 |
| 26 | @ObservationIgnored private var overlayCache: (text: String, record: FileRecord)? | ||
| 25 | 27 | ||
| 26 | /// `directory` holds the index and the list of roots; nil keeps both in memory. | 28 | /// `directory` holds the index and the list of roots; nil keeps both in memory. |
| 27 | public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { | 29 | public init(directory: URL?, settings: IndexSettings = IndexSettings()) throws { |
| @@ -192,6 +194,20 @@ public final class WorkspaceModel { | |||
| 192 | (try? index.search(query, overlay: overlay)) ?? [] | 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 | public func quickOpen(_ query: String) -> [FileEntry] { | 211 | public func quickOpen(_ query: String) -> [FileEntry] { |
| 196 | FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) | 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 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) | 28 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 29 | } detail: { | 29 | } detail: { |
| 30 | if session.document != nil { | 30 | if session.path != nil { |
| 31 | EditorView(session: session) | 31 | EditorView(session: session) |
| 32 | } else { | 32 | } else { |
| 33 | ContentUnavailableView( | 33 | ContentUnavailableView( |
| @@ -135,7 +135,7 @@ struct OutlineColumn: View { | |||
| 135 | .buttonStyle(.plain) | 135 | .buttonStyle(.plain) |
| 136 | } | 136 | } |
| 137 | .overlay { | 137 | .overlay { |
| 138 | if session.document != nil, session.outline.isEmpty { | 138 | if session.path != nil, session.outline.isEmpty { |
| 139 | ContentUnavailableView("No headings", systemImage: "list.bullet.indent") | 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 | var editor: OrgEditor? | 12 | var editor: OrgEditor? |
| 13 | var path: String? | 13 | var path: String? |
| 14 | var revealID = 0 | 14 | var revealID = 0 |
| 15 | var diskID = 0 | ||
| 16 | } | 15 | } |
| 17 | 16 | ||
| 18 | func makeCoordinator() -> Coordinator { Coordinator() } | 17 | func makeCoordinator() -> Coordinator { Coordinator() } |
| @@ -35,15 +34,12 @@ struct EditorView: NSViewRepresentable { | |||
| 35 | container.addSubview(scroll) | 34 | container.addSubview(scroll) |
| 36 | container.window?.makeFirstResponder(editor.textView) | 35 | container.window?.makeFirstResponder(editor.textView) |
| 37 | coordinator.editor = editor | 36 | coordinator.editor = editor |
| 37 | session.attach(editor) | ||
| 38 | } | 38 | } |
| 39 | } | 39 | } |
| 40 | if let request = session.revealRequest, request.id != coordinator.revealID { | 40 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 41 | coordinator.revealID = request.id | 41 | coordinator.revealID = request.id |
| 42 | coordinator.editor?.reveal(request.offset) | 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 | import Foundation | 1 | import Foundation |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgDocument | ||
| 3 | import OrgIndex | 4 | import OrgIndex |
| 4 | import Testing | 5 | import Testing |
| 5 | @testable import OrgApp | 6 | @testable import OrgApp |
| @@ -90,7 +91,7 @@ struct DocumentSessionTests { | |||
| 90 | @Test func openShowAndReload() throws { | 91 | @Test func openShowAndReload() throws { |
| 91 | let notes = try Folder() | 92 | let notes = try Folder() |
| 92 | let file = try notes.write("a.org", "* One\n* Two\n") | 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 | session.open(file.path) | 95 | session.open(file.path) |
| 95 | #expect(session.outline.map(\.title) == ["One", "Two"]) | 96 | #expect(session.outline.map(\.title) == ["One", "Two"]) |
| 96 | 97 | ||
| @@ -101,7 +102,6 @@ struct DocumentSessionTests { | |||
| 101 | try notes.write("a.org", "intro\n* One\n* Two\n") | 102 | try notes.write("a.org", "intro\n* One\n* Two\n") |
| 102 | session.fileChanged(file.path) | 103 | session.fileChanged(file.path) |
| 103 | #expect(session.outline.map(\.start) == [6, 12]) | 104 | #expect(session.outline.map(\.start) == [6, 12]) |
| 104 | #expect(session.diskBytes != nil) | ||
| 105 | session.show(location) | 105 | session.show(location) |
| 106 | #expect(session.revealRequest?.offset == 12) | 106 | #expect(session.revealRequest?.offset == 12) |
| 107 | } | 107 | } |
| @@ -113,3 +113,99 @@ struct DocumentSessionTests { | |||
| 113 | #expect(session.document == nil) | 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 | } | ||