Report each buffer's changes as its own !60

merged merged by cmc on 2026-10-06 00:16 UTC · krz/orgstar:fix-buffer-changes into main

3 files changed, +41 −1

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +9
@@ -221,6 +221,13 @@ public final class DocumentSession {
221 refresh() 221 refresh()
222 } 222 }
223 223
224 /// The buffer of `path` changed in its editor: the current one updates its views, another
225 /// saves on its own.
226 public func bufferChanged(_ path: String) {
227 guard let entry = entry(path) else { return }
228 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
229 }
230
224 /// The buffer changed through typing. The outline follows once typing pauses. 231 /// The buffer changed through typing. The outline follows once typing pauses.
225 public func bufferChanged() { 232 public func bufferChanged() {
226 refresh(outline: false) 233 refresh(outline: false)
@@ -556,6 +563,8 @@ public final class DocumentSession {
556 entry.conflict = "\(name) changed on disk, and the changes conflict with yours." 563 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
557 case .merged, .reloaded: 564 case .merged, .reloaded:
558 entry.conflict = nil 565 entry.conflict = nil
566 // Edits merged with the disk's still need saving.
567 scheduleAutosave(entry)
559 case .unchanged: 568 case .unchanged:
560 break 569 break
561 } 570 }
Sources/Orgstar/EditorView.swift +2 −1
@@ -83,7 +83,8 @@ struct EditorView: NSViewRepresentable {
83 guard let document = session.document else { return nil } 83 guard let document = session.document else { return nil }
84 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) 84 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
85 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode) 85 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode)
86 editor.onChange = { [weak session] in session?.bufferChanged() } 86 let path = session.path ?? ""
87 editor.onChange = { [weak session] in session?.bufferChanged(path) }
87 editor.onMessage = { [weak commands] in commands?.show($0) } 88 editor.onMessage = { [weak commands] in commands?.show($0) }
88 editor.onKeysPending = { [weak commands] keys, next in 89 editor.onKeysPending = { [weak commands] keys, next in
89 commands?.pendingKeys = keys 90 commands?.pendingKeys = keys
Tests/OrgEditorAppKitTests/EditorTests.swift +30
@@ -252,6 +252,36 @@ struct BufferTests {
252 h.checkInSync() 252 h.checkInSync()
253 } 253 }
254 254
255 /// Each buffer's editor reports its own changes: a background buffer's edit saves its
256 /// file, not the shown one's.
257 @Test func backgroundEditorsSaveTheirOwnFiles() async throws {
258 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
259 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
260 defer { try? FileManager.default.removeItem(at: folder) }
261 let a = folder.appendingPathComponent("a.org"), b = folder.appendingPathComponent("b.org")
262 try Data("* A\n".utf8).write(to: a)
263 try Data("* B\n".utf8).write(to: b)
264 let session = DocumentSession(recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
265 session.autosaveDelay = .milliseconds(10)
266 var harnesses: [String: Harness] = [:]
267 for file in [a, b] {
268 session.open(file.path)
269 let h = Harness(try #require(session.document))
270 let path = file.path
271 h.editor.onChange = { [weak session] in session?.bufferChanged(path) }
272 session.attach(h.editor)
273 harnesses[path] = h
274 }
275 #expect(session.path == b.path)
276 harnesses[a.path]!.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
277 #expect(session.dirtyPaths == [a.path])
278 #expect(!session.isDirty)
279 for _ in 0..<50 where (try? String(contentsOf: a, encoding: .utf8)) != "x* A\n" {
280 try await Task.sleep(for: .milliseconds(20))
281 }
282 #expect(try String(contentsOf: a, encoding: .utf8) == "x* A\n")
283 }
284
255 /// The wiring the app uses: the editor is the session's buffer and reports typing. 285 /// The wiring the app uses: the editor is the session's buffer and reports typing.
256 @Test func sessionSavesWhatIsTyped() async throws { 286 @Test func sessionSavesWhatIsTyped() async throws {
257 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)") 287 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")