krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 2c2f13e5b8

2c2f13e5b8bac54556144d3a5522cc5ab5be3db6

parent: b12f088c30

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 00:18 UTC

Refile and archive into open buffers

Refile and archive to another file read it from disk and wrote through
the save path even when the file was open, so its buffer only caught
up by a later merge. A target open in a buffer now takes the subtree
there, with its unsaved edits, and saves as that buffer does. Refile
targets use their indexed offset only when the text is what was
indexed, not when a buffer has unsaved edits above them.

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +39 −6
@@ -25,11 +25,11 @@ public struct RefileTarget: Sendable, Hashable {
25 self.contentHash = contentHash 25 self.contentHash = contentHash
26 } 26 }
27 27
28 /// The heading's start in `state`: its indexed offset if the file hasn't changed, else the 28 /// The heading's start in `state`: its indexed offset if the text is what was indexed,
29 /// first heading with the same title and level. Nil for the end of the file. 29 /// else the first heading with the same title and level. Nil for the end of the file.
30 func locate(in state: DocumentState) -> Int?? { 30 func locate(in state: DocumentState) -> Int?? {
31 guard let start else { return .some(nil) } 31 guard let start else { return .some(nil) }
32 if FileRecord.hash(state.mergeBase) == contentHash { return .some(start) } 32 if !state.isDirty, FileRecord.hash(state.mergeBase) == contentHash { return .some(start) }
33 let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level } 33 let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level }
34 return entry.map { .some($0.start) } 34 return entry.map { .some($0.start) }
35 } 35 }
@@ -369,14 +369,28 @@ public final class DocumentSession {
369 return "Refiled to \(target.label)" 369 return "Refiled to \(target.label)"
370 } 370 }
371 let url = URL(fileURLWithPath: target.path) 371 let url = URL(fileURLWithPath: target.path)
372 let targetName = (target.path as NSString).lastPathComponent
373 // A target open in a buffer takes the subtree there, with its unsaved edits.
374 if let open = entry(target.path) {
375 let state = open.buffer.document
376 guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." }
377 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
378 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
379 let edit = EditResult(baseRevision: state.revision, edits: [result.target])
380 if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
381 changed(open)
382 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
383 bufferChanged()
384 return "Refiled to \(target.label)"
385 }
372 do { 386 do {
373 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." } 387 guard let bytes = try fileSystem.read(url) else { return "\(targetName) no longer exists." }
374 var state = DocumentState(bytes: bytes, defaults: defaults) 388 var state = DocumentState(bytes: bytes, defaults: defaults)
375 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." } 389 guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." }
376 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." } 390 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
377 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } 391 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
378 try state.apply([result.target], baseRevision: state.revision) 392 try state.apply([result.target], baseRevision: state.revision)
379 if case .conflict = try saver.save(&state, to: url) { return "\((target.path as NSString).lastPathComponent) changed on disk; nothing was refiled." } 393 if case .conflict = try saver.save(&state, to: url) { return "\(targetName) changed on disk; nothing was refiled." }
380 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message } 394 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
381 bufferChanged() 395 bufferChanged()
382 return "Refiled to \(target.label)" 396 return "Refiled to \(target.label)"
@@ -394,6 +408,25 @@ public final class DocumentSession {
394 case .success(let value): destination = value 408 case .success(let value): destination = value
395 case .failure(.message(let message)): return message 409 case .failure(.message(let message)): return message
396 } 410 }
411 // An archive file open in a buffer takes the subtree there.
412 if !destination.isSameFile, let open = entry(destination.path) {
413 let state = open.buffer.document
414 let name = (destination.path as NSString).lastPathComponent
415 guard state.isEditable else { return "\(name) isn't UTF-8, so it can't be changed." }
416 let edits: Archive.Edits
417 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: state.text) {
418 case .success(let value): edits = value
419 case .failure(.message(let message)): return message
420 }
421 if let archiveEdit = edits.archive {
422 let edit = EditResult(baseRevision: state.revision, edits: [archiveEdit])
423 if case .failed(let message) = open.buffer.perform(PrecomputedEdit(edit), now: now, answers: [:]) { return message }
424 changed(open)
425 }
426 if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message }
427 bufferChanged()
428 return "Archived to \(name)"
429 }
397 do { 430 do {
398 let url = URL(fileURLWithPath: destination.path) 431 let url = URL(fileURLWithPath: destination.path)
399 let existing = destination.isSameFile ? nil : try fileSystem.read(url) 432 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
Tests/OrgAppTests/AppTests.swift +49
@@ -305,6 +305,55 @@ struct MoveSubtreeTests {
305 } 305 }
306} 306}
307 307
308@MainActor
309struct MoveIntoOpenBufferTests {
310 /// A target open with unsaved edits above its heading takes the subtree in its buffer,
311 /// under the right heading, and the disk keeps the saved text.
312 @Test func refileIntoAnOpenBuffer() async throws {
313 let notes = try Folder()
314 let file = try notes.write("a.org", "* task\nbody\n")
315 let other = try notes.write("b.org", "* inbox\n* projects\n")
316 let workspace = try WorkspaceModel(directory: nil)
317 try await workspace.addRoot(notes.url)
318 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
319 session.saveMode = .explicit
320 session.open(other.path)
321 let projects = try #require(workspace.refileTargets(openPath: nil, document: nil).first { $0.label == "b.org/projects" })
322 _ = session.perform(InsertText(text: "* new\n"), path: other.path, at: 0, expect: nil)
323 session.open(file.path)
324 #expect(session.refile(to: projects) == "Refiled to b.org/projects")
325 #expect(session.document?.text == "")
326 #expect(session.openDocuments.first { $0.path == other.path }?.document.text == "* new\n* inbox\n* projects\n** task\nbody\n")
327 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n* projects\n")
328 }
329
330 @Test func archiveIntoAnOpenBuffer() throws {
331 let notes = try Folder()
332 let file = try notes.write("a.org", "* done\n* keep\n")
333 let archive = try notes.write("a.org_archive", "* old\n")
334 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
335 session.saveMode = .explicit
336 session.open(archive.path)
337 session.open(file.path)
338 #expect(session.archive(now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
339 #expect(session.document?.text == "* keep\n")
340 let text = try #require(session.openDocuments.first { $0.path == archive.path }?.document.text)
341 #expect(text.hasPrefix("* old\n") && text.contains("* done\n:PROPERTIES:\n:ARCHIVE_TIME:"))
342 #expect(try String(contentsOf: archive, encoding: .utf8) == "* old\n")
343 }
344}
345
346/// Inserts text at the caret, for tests.
347struct InsertText: OrgCommand {
348 let text: String
349 var id: String { "test.insert" }
350 var title: String { "Insert" }
351 func applies(in context: EditContext) -> Bool { true }
352 func run(in context: EditContext) -> CommandStep {
353 .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: context.caret..<context.caret, replacement: text)]))
354 }
355}
356
308/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would. 357/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would.
309@MainActor 358@MainActor
310enum StateBufferCaret { 359enum StateBufferCaret {