Refile and archive into open buffers !61

merged merged by cmc on 2026-10-06 00:18 UTC · krz/orgstar:fix-refile-open-buffers into main

2 files changed, +88 −6

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +39 −6
@@ -25,11 +25,11 @@ public struct RefileTarget: Sendable, Hashable {
2525 self.contentHash = contentHash
2626 }
2727
28 /// The heading's start in `state`: its indexed offset if the file hasn't changed, else the
29 /// first heading with the same title and level. Nil for the end of the file.
28 /// The heading's start in `state`: its indexed offset if the text is what was indexed,
29 /// else the first heading with the same title and level. Nil for the end of the file.
3030 func locate(in state: DocumentState) -> Int?? {
3131 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) }
3333 let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level }
3434 return entry.map { .some($0.start) }
3535 }
@@ -369,14 +369,28 @@ public final class DocumentSession {
369369 return "Refiled to \(target.label)"
370370 }
371371 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 }
372386 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." }
374388 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." }
376390 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
377391 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
378392 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." }
380394 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
381395 bufferChanged()
382396 return "Refiled to \(target.label)"
@@ -394,6 +408,25 @@ public final class DocumentSession {
394408 case .success(let value): destination = value
395409 case .failure(.message(let message)): return message
396410 }
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 }
397430 do {
398431 let url = URL(fileURLWithPath: destination.path)
399432 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
Tests/OrgAppTests/AppTests.swift +49
@@ -305,6 +305,55 @@ struct MoveSubtreeTests {
305305 }
306306}
307307
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
308357/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would.
309358@MainActor
310359enum StateBufferCaret {