Refile and archive into open buffers !61
2 files changed, +88 −6
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 | ||
| 309 | struct 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. | ||
| 347 | struct 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 |
| 310 | enum StateBufferCaret { | 359 | enum StateBufferCaret { |