krz/orgstar

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

Commit 71333253c9

71333253c9544b857c22f69f56d3af166b7ef573

parent: 265b9d6362

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 05:47 UTC

Conflict diffs, Syncthing conflict copies, recovery versions

- A save conflict opens a sheet with the diff of the disk version against
  yours: Keep Mine, Use Disk Version, Merge with Markers (both changes in the
  buffer, conflicting lines between git-style markers; the disk version
  becomes the base) or Decide Later.
- Resolve Sync Conflicts…: Syncthing's name.sync-conflict-…  copies of the
  open file against it; keep the file, use the copy or merge with markers.
  The copy goes to recovery. Opening a file with copies says so.
- Recovery Versions…: the file's kept versions, newest first, each against
  the buffer; Restore puts one back as an undoable edit.

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +99
@@ -347,6 +347,105 @@ public final class DocumentSession {
347 refresh() 347 refresh()
348 } 348 }
349 349
350 // MARK: - Comparing versions
351
352 /// The buffer, the file on disk and the version both started from, while they conflict.
353 public func conflictVersions() -> (mine: String, disk: String, base: String)? {
354 guard current?.conflict != nil, let buffer, let url, let disk = try? fileSystem.read(url) else { return nil }
355 return (buffer.document.text, SourceText(bytes: disk).text, SourceText(bytes: buffer.document.mergeBase).text)
356 }
357
358 /// Resolves a conflict as `git merge` leaves one: the buffer holds both sides' changes, those
359 /// that conflict between markers, to fix and save. The disk version becomes the base, and
360 /// the buffer as it was goes to recovery.
361 public func mergeConflictWithMarkers() {
362 guard let versions = conflictVersions(), let buffer, let url, let disk = try? fileSystem.read(url) else { return }
363 do {
364 try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
365 let merged = mergeWithMarkers(base: versions.base, ours: versions.mine, theirs: versions.disk, oursLabel: "yours", theirsLabel: "disk")
366 try buffer.update { state in
367 state = DocumentState(bytes: disk, defaults: state.defaults)
368 try state.apply(lineEdits(from: versions.disk, to: merged), baseRevision: state.revision)
369 }
370 current?.conflict = nil
371 } catch {
372 lastError = "Can't merge \(name): \(error.localizedDescription)"
373 }
374 refresh()
375 }
376
377 /// Syncthing's conflict copies of the open file beside it: `name.sync-conflict-DATE-TIME-ID.ext`.
378 public func syncConflictCopies() -> [URL] {
379 guard let url else { return [] }
380 let folder = url.deletingLastPathComponent()
381 let stem = NSRegularExpression.escapedPattern(for: url.deletingPathExtension().lastPathComponent)
382 let ext = url.pathExtension.isEmpty ? "" : "\\." + NSRegularExpression.escapedPattern(for: url.pathExtension)
383 let pattern = "^" + stem + "\\.sync-conflict-[0-9]{8}-[0-9]{6}-[A-Z0-9]+" + ext + "$"
384 let names = (try? FileManager.default.contentsOfDirectory(atPath: folder.path)) ?? []
385 return names.filter { $0.range(of: pattern, options: .regularExpression) != nil }.sorted().map { folder.appendingPathComponent($0) }
386 }
387
388 /// What to do with a conflict copy: keep the file as it is, take the copy's text, or merge
389 /// the two with markers. The copy goes to recovery and is removed.
390 public enum CopyResolution { case keepFile, useCopy, merge }
391
392 public func resolve(copy: URL, _ resolution: CopyResolution) -> String? {
393 guard let buffer, let url else { return "No file is open" }
394 do {
395 guard let bytes = try fileSystem.read(copy) else { return "\(copy.lastPathComponent) is gone" }
396 let copyText = SourceText(bytes: bytes).text
397 let mine = buffer.document.text
398 let new: String? = switch resolution {
399 case .keepFile: nil
400 case .useCopy: copyText
401 case .merge: mergeWithMarkers(base: nil, ours: mine, theirs: copyText, oursLabel: url.lastPathComponent, theirsLabel: copy.lastPathComponent)
402 }
403 if let new, new != mine {
404 let result = EditResult(baseRevision: buffer.document.revision, edits: lineEdits(from: mine, to: new))
405 if case .failed(let message) = buffer.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
406 }
407 try recovery.keep(bytes, for: url, label: "sync-conflict")
408 try FileManager.default.removeItem(at: copy)
409 } catch {
410 return "Can't resolve \(copy.lastPathComponent): \(error.localizedDescription)"
411 }
412 if let current { changed(current) }
413 return nil
414 }
415
416 /// Versions of the open file kept in recovery, newest first, with when and why each was
417 /// kept.
418 public func recoveryVersions() -> [(url: URL, date: Date, label: String)] {
419 guard let url, let store = recovery as? FileRecoveryStore, let versions = try? store.versions(for: url) else { return [] }
420 // `STAMP-LABEL-UUID8-NAME`, as `FileRecoveryStore.keep` names them.
421 return versions.reversed().map { version in
422 let m = version.lastPathComponent.firstMatch(of: /^([0-9]+)-(.+?)-([0-9A-Fa-f]{8})-/)
423 let nanos = m.flatMap { UInt64($0.1) } ?? 0
424 return (version, Date(timeIntervalSince1970: Double(nanos) / 1_000_000_000), m.map { String($0.2) } ?? "")
425 }
426 }
427
428 /// The text of a kept version.
429 public func recoveryText(_ version: URL) -> String? {
430 (try? fileSystem.read(version)).flatMap { $0 }.map { SourceText(bytes: $0).text }
431 }
432
433 /// Puts a kept version's text in the buffer, as an edit that can be undone; the buffer as it
434 /// was goes to recovery.
435 public func restore(_ version: URL) -> String? {
436 guard let buffer, let url, let text = recoveryText(version) else { return "Can't read that version" }
437 do {
438 try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
439 } catch {
440 return "Can't keep the current version: \(error.localizedDescription)"
441 }
442 let mine = buffer.document.text
443 let result = EditResult(baseRevision: buffer.document.revision, edits: lineEdits(from: mine, to: text))
444 if case .failed(let message) = buffer.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
445 if let current { changed(current) }
446 return nil
447 }
448
350 // MARK: - Moving subtrees 449 // MARK: - Moving subtrees
351 450
352 /// The current context of the buffer, as commands see it. 451 /// The current context of the buffer, as commands see it.
Sources/OrgDocument/Merge.swift +86
@@ -173,3 +173,89 @@ private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
173 } 173 }
174 return pairs.reversed() 174 return pairs.reversed()
175} 175}
176
177/// One line of a diff, with its ending.
178public struct DiffLine: Sendable, Equatable {
179 public enum Kind: Sendable, Equatable { case same, removed, added }
180 public let kind: Kind
181 public let text: String
182}
183
184/// The lines of `old` and `new` in order: kept, removed from `old`, added in `new`.
185public func lineDiff(old: String, new: String) -> [DiffLine] {
186 let oldLines = textLines(old)
187 let newLines = textLines(new)
188 var ids = LineIDs()
189 let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
190 var result: [DiffLine] = []
191 var i = 0, j = 0
192 for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
193 result += oldLines[i..<pi].map { DiffLine(kind: .removed, text: $0) }
194 result += newLines[j..<pj].map { DiffLine(kind: .added, text: $0) }
195 if pi < oldLines.count, pj < newLines.count { result.append(DiffLine(kind: .same, text: oldLines[pi])) }
196 i = pi + 1
197 j = pj + 1
198 }
199 return result
200}
201
202/// `ours` and `theirs` merged against `base` where they don't conflict, as `threeWayMerge`
203/// does, with each conflict between git's markers. Without a base, every difference is a
204/// conflict.
205public func mergeWithMarkers(base: String?, ours: String, theirs: String, oursLabel: String, theirsLabel: String) -> String {
206 func block(_ o: String, _ t: String) -> String {
207 func ended(_ s: String) -> String { s.isEmpty || s.hasSuffix("\n") ? s : s + "\n" }
208 return "<<<<<<< \(oursLabel)\n" + ended(o) + "=======\n" + ended(t) + ">>>>>>> \(theirsLabel)\n"
209 }
210 guard let base else {
211 var out = ""
212 var o = "", t = ""
213 func flush() {
214 if !o.isEmpty || !t.isEmpty { out += block(o, t) }
215 o = ""
216 t = ""
217 }
218 for line in lineDiff(old: ours, new: theirs) {
219 switch line.kind {
220 case .same:
221 flush()
222 out += line.text
223 case .removed: o += line.text
224 case .added: t += line.text
225 }
226 }
227 flush()
228 return out
229 }
230 let baseLines = textLines(base)
231 let ourLines = textLines(ours)
232 let theirLines = textLines(theirs)
233 var ids = LineIDs()
234 let baseIDs = ids.encode(baseLines)
235 var toOurs = [Int?](repeating: nil, count: baseLines.count)
236 for (b, o) in matchingLines(baseIDs, ids.encode(ourLines)) { toOurs[b] = o }
237 var toTheirs = [Int?](repeating: nil, count: baseLines.count)
238 for (b, t) in matchingLines(baseIDs, ids.encode(theirLines)) { toTheirs[b] = t }
239 var merged = ""
240 var nextBase = 0, nextOurs = 0, nextTheirs = 0
241 func resolve(_ baseEnd: Int, _ oursEnd: Int, _ theirsEnd: Int) {
242 let b = baseLines[nextBase..<baseEnd], o = ourLines[nextOurs..<oursEnd], t = theirLines[nextTheirs..<theirsEnd]
243 if o.elementsEqual(b) {
244 merged += t.joined()
245 } else if t.elementsEqual(b) || o.elementsEqual(t) {
246 merged += o.joined()
247 } else {
248 merged += block(o.joined(), t.joined())
249 }
250 }
251 for k in baseLines.indices {
252 guard let o = toOurs[k], let t = toTheirs[k], o >= nextOurs, t >= nextTheirs else { continue }
253 resolve(k, o, t)
254 merged += baseLines[k]
255 nextBase = k + 1
256 nextOurs = o + 1
257 nextTheirs = t + 1
258 }
259 resolve(baseLines.count, ourLines.count, theirLines.count)
260 return merged
261}
Sources/OrgEditorAppKit/EditorCommands.swift +2
@@ -86,6 +86,8 @@ public enum EditorCommands {
86 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), 86 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
87 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), 87 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
88 EditorCommand(id: "app.export-dialog", title: "Export…", action: .app), 88 EditorCommand(id: "app.export-dialog", title: "Export…", action: .app),
89 EditorCommand(id: "app.sync-conflicts", title: "Resolve Sync Conflicts…", action: .app),
90 EditorCommand(id: "app.recovery", title: "Recovery Versions…", action: .app),
89 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), 91 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
90 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), 92 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
91 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), 93 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app),
Sources/Orgstar/ContentView.swift +23 −7
@@ -208,6 +208,9 @@ struct ContentView: View {
208 if let path = session.path, selectedFile != path { selectedFile = path } 208 if let path = session.path, selectedFile != path { selectedFile = path }
209 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) } 209 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
210 BufferRestore.save(session) 210 BufferRestore.save(session)
211 // Syncthing's conflict copies of the file.
212 let copies = session.syncConflictCopies().count
213 if copies > 0 { commands.show("\(copies) sync conflict \(copies == 1 ? "copy" : "copies") of this file; Resolve Sync Conflicts compares them.") }
211 } 214 }
212 .onChange(of: session.openPaths) { BufferRestore.save(session) } 215 .onChange(of: session.openPaths) { BufferRestore.save(session) }
213 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } 216 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
@@ -220,6 +223,12 @@ struct ContentView: View {
220 ColumnViewSheet(view: view) { offset in commands.editor?.reveal(offset) } 223 ColumnViewSheet(view: view) { offset in commands.editor?.reveal(offset) }
221 case .export(_, let source): 224 case .export(_, let source):
222 ExportSheet(source: source) { format, destination, open in export(format, to: destination, open: open) } 225 ExportSheet(source: source) { format, destination, open in export(format, to: destination, open: open) }
226 case .conflict:
227 ConflictSheet(session: session)
228 case .syncCopies:
229 SyncConflictSheet(session: session) { commands.show($0) }
230 case .recovery:
231 RecoverySheet(session: session) { commands.show($0) }
223 } 232 }
224 } 233 }
225 .sheet(item: $blockEdit) { request in 234 .sheet(item: $blockEdit) { request in
@@ -239,12 +248,10 @@ struct ContentView: View {
239 .onChange(of: session.conflict) { _, conflict in 248 .onChange(of: session.conflict) { _, conflict in
240 if conflict != nil { commands.showingConflict = true } 249 if conflict != nil { commands.showingConflict = true }
241 } 250 }
242 .alert("Conflicting changes", isPresented: $commands.showingConflict) { 251 .onChange(of: commands.showingConflict) { _, showing in
243 Button("Keep Mine") { session.keepMine() } 252 guard showing else { return }
244 Button("Use Disk Version") { session.useDiskVersion() } 253 commands.showingConflict = false
245 Button("Decide Later", role: .cancel) {} 254 if session.conflict != nil { panel = .conflict(UUID()) }
246 } message: {
247 Text((session.conflict ?? "") + " The version you don't keep is saved in the recovery folder.")
248 } 255 }
249 .onChange(of: workspace.changedFile?.version) { 256 .onChange(of: workspace.changedFile?.version) {
250 if let path = workspace.changedFile?.path { session.fileChanged(path) } 257 if let path = workspace.changedFile?.path { session.fileChanged(path) }
@@ -595,6 +602,12 @@ struct ContentView: View {
595 case "app.capture": 602 case "app.capture":
596 NSApp.activate() 603 NSApp.activate()
597 openWindow(id: "capture") 604 openWindow(id: "capture")
605 case "app.sync-conflicts":
606 guard session.path != nil else { return commands.show("No file is open") }
607 panel = .syncCopies(UUID())
608 case "app.recovery":
609 guard session.path != nil else { return commands.show("No file is open") }
610 panel = .recovery(UUID())
598 case "app.export-dialog": 611 case "app.export-dialog":
599 guard let path = session.path else { return commands.show("No file is open") } 612 guard let path = session.path else { return commands.show("No file is open") }
600 panel = .export(UUID(), URL(fileURLWithPath: path)) 613 panel = .export(UUID(), URL(fileURLWithPath: path))
@@ -879,10 +892,13 @@ struct BufferTabs: View {
879enum Panel: Identifiable { 892enum Panel: Identifiable {
880 case columns(UUID, ColumnView.View) 893 case columns(UUID, ColumnView.View)
881 case export(UUID, URL) 894 case export(UUID, URL)
895 case conflict(UUID)
896 case syncCopies(UUID)
897 case recovery(UUID)
882 898
883 var id: UUID { 899 var id: UUID {
884 switch self { 900 switch self {
885 case .columns(let id, _), .export(let id, _): id 901 case .columns(let id, _), .export(let id, _), .conflict(let id), .syncCopies(let id), .recovery(let id): id
886 } 902 }
887 } 903 }
888} 904}
Sources/Orgstar/VersionViews.swift added +210
@@ -0,0 +1,210 @@
1import AppKit
2import OrgApp
3import OrgDocument
4import SwiftUI
5
6/// Lines removed and added between two versions, with a few lines of context around each change.
7struct DiffView: View {
8 let lines: [DiffLine]
9 var context = 3
10
11 init(old: String, new: String) {
12 lines = lineDiff(old: old, new: new)
13 }
14
15 /// Lines to show, nil standing for a run of unchanged lines left out.
16 private var shown: [DiffLine?] {
17 let changed = lines.indices.filter { lines[$0].kind != .same }
18 guard !changed.isEmpty else { return [] }
19 var keep = Set<Int>()
20 for i in changed { keep.formUnion(max(0, i - context)...min(lines.count - 1, i + context)) }
21 var out: [DiffLine?] = []
22 var skipped = false
23 for i in lines.indices {
24 if keep.contains(i) {
25 out.append(lines[i])
26 skipped = false
27 } else if !skipped {
28 out.append(nil)
29 skipped = true
30 }
31 }
32 return out
33 }
34
35 var body: some View {
36 let shown = shown
37 ScrollView([.vertical, .horizontal]) {
38 VStack(alignment: .leading, spacing: 0) {
39 if shown.isEmpty { Text("No differences").foregroundStyle(.secondary).padding(8) }
40 ForEach(shown.indices, id: \.self) { i in
41 if let line = shown[i] {
42 Text(prefix(line.kind) + line.text.trimmingCharacters(in: .newlines))
43 .frame(maxWidth: .infinity, alignment: .leading)
44 .background(background(line.kind))
45 } else {
46 Text("⋯").foregroundStyle(.secondary)
47 }
48 }
49 }
50 .font(.system(.callout, design: .monospaced))
51 .textSelection(.enabled)
52 .padding(8)
53 }
54 .defaultScrollAnchor(.topLeading)
55 .background(Color(nsColor: .textBackgroundColor))
56 }
57
58 private func prefix(_ kind: DiffLine.Kind) -> String {
59 switch kind {
60 case .same: " "
61 case .removed: "- "
62 case .added: "+ "
63 }
64 }
65
66 private func background(_ kind: DiffLine.Kind) -> Color {
67 switch kind {
68 case .same: .clear
69 case .removed: .red.opacity(0.18)
70 case .added: .green.opacity(0.18)
71 }
72 }
73}
74
75/// The buffer and the file on disk changed differently: what differs, and how to settle it.
76struct ConflictSheet: View {
77 let session: DocumentSession
78 @Environment(\.dismiss) private var dismiss
79
80 var body: some View {
81 VStack(alignment: .leading, spacing: 10) {
82 Text("Conflicting changes").font(.headline)
83 Text((session.conflict ?? "") + " Lines marked - are on disk, + in your version. The version you don't keep is saved in the recovery folder.")
84 .foregroundStyle(.secondary)
85 .fixedSize(horizontal: false, vertical: true)
86 if let versions = session.conflictVersions() {
87 DiffView(old: versions.disk, new: versions.mine)
88 }
89 HStack {
90 Button("Decide Later") { dismiss() }.keyboardShortcut(.cancelAction)
91 Spacer()
92 Button("Use Disk Version") { session.useDiskVersion(); dismiss() }
93 Button("Merge with Markers") { session.mergeConflictWithMarkers(); dismiss() }
94 .help("Both sets of changes in the buffer, conflicting lines between <<<<<<< and >>>>>>>")
95 Button("Keep Mine") { session.keepMine(); dismiss() }.keyboardShortcut(.defaultAction)
96 }
97 }
98 .padding()
99 .frame(minWidth: 640, minHeight: 440)
100 }
101}
102
103/// Syncthing's conflict copies of the open file: each against the file, and what to keep.
104struct SyncConflictSheet: View {
105 let session: DocumentSession
106 let report: (String) -> Void
107 @State private var copies: [URL] = []
108 @State private var selected: URL?
109 @Environment(\.dismiss) private var dismiss
110
111 var body: some View {
112 VStack(alignment: .leading, spacing: 10) {
113 Text("Sync conflict copies").font(.headline)
114 if copies.isEmpty {
115 Text("No conflict copies of this file.").foregroundStyle(.secondary)
116 } else {
117 Picker("Copy", selection: $selected) {
118 ForEach(copies, id: \.self) { Text($0.lastPathComponent).tag(Optional($0)) }
119 }
120 Text("Lines marked - are in the file, + in the copy. The copy goes to the recovery folder when it's resolved.")
121 .foregroundStyle(.secondary)
122 if let selected, let copy = session.recoveryText(selected), let mine = session.document?.text {
123 DiffView(old: mine, new: copy)
124 }
125 }
126 HStack {
127 Button("Close") { dismiss() }.keyboardShortcut(.cancelAction)
128 Spacer()
129 if let selected {
130 Button("Keep File") { resolve(selected, .keepFile) }
131 Button("Merge with Markers") { resolve(selected, .merge) }
132 Button("Use Copy") { resolve(selected, .useCopy) }
133 }
134 }
135 }
136 .padding()
137 .frame(minWidth: 640, minHeight: 440)
138 .onAppear(perform: reload)
139 }
140
141 private func reload() {
142 copies = session.syncConflictCopies()
143 if selected.map({ !copies.contains($0) }) ?? true { selected = copies.first }
144 }
145
146 private func resolve(_ copy: URL, _ resolution: DocumentSession.CopyResolution) {
147 if let message = session.resolve(copy: copy, resolution) { report(message) }
148 reload()
149 if copies.isEmpty { dismiss() }
150 }
151}
152
153/// Versions of the open file kept in the recovery folder, newest first, against the buffer.
154struct RecoverySheet: View {
155 let session: DocumentSession
156 let report: (String) -> Void
157 @State private var versions: [(url: URL, date: Date, label: String)] = []
158 @State private var selected: URL?
159 @Environment(\.dismiss) private var dismiss
160
161 var body: some View {
162 HSplitView {
163 List(versions, id: \.url, selection: $selected) { version in
164 VStack(alignment: .leading) {
165 Text(version.date.formatted(date: .abbreviated, time: .standard))
166 Text(Self.describe(version.label)).font(.caption).foregroundStyle(.secondary)
167 }
168 .tag(version.url)
169 }
170 .frame(minWidth: 200, idealWidth: 220)
171 VStack(alignment: .leading, spacing: 10) {
172 Text("Lines marked - are in the buffer, + in the kept version.").foregroundStyle(.secondary)
173 if let selected, let kept = session.recoveryText(selected), let mine = session.document?.text {
174 DiffView(old: mine, new: kept)
175 } else {
176 Spacer()
177 }
178 HStack {
179 Button("Close") { dismiss() }.keyboardShortcut(.cancelAction)
180 if let selected {
181 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([selected]) }
182 }
183 Spacer()
184 if let selected {
185 Button("Restore") {
186 if let message = session.restore(selected) { report(message) } else { report("Restored; undo brings back what was there.") }
187 dismiss()
188 }
189 .keyboardShortcut(.defaultAction)
190 }
191 }
192 }
193 .padding()
194 }
195 .frame(minWidth: 760, minHeight: 460)
196 .onAppear {
197 versions = session.recoveryVersions()
198 selected = versions.first?.url
199 }
200 }
201
202 static func describe(_ label: String) -> String {
203 switch label {
204 case "local": "Your version, replaced"
205 case "external": "Disk version, replaced"
206 case "sync-conflict": "Sync conflict copy"
207 default: label
208 }
209 }
210}
Tests/OrgAppTests/VersionTests.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgApp
6
7@MainActor
8struct VersionTests {
9 func edit(_ session: DocumentSession, _ path: String, _ text: String) {
10 let current = session.document!.text
11 _ = session.perform(PrecomputedEdit(EditResult(baseRevision: session.document!.revision, edits: lineEdits(from: current, to: text))),
12 path: path, at: 0, expect: nil)
13 }
14
15 @Test func conflictsMergeWithMarkers() throws {
16 let notes = try Folder()
17 let file = try notes.write("a.org", "* A\nline one\nline two\n")
18 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
19 session.open(file.path)
20 edit(session, file.path, "* A\nline ONE mine\nline two\n")
21 try notes.write("a.org", "* A\nline one disk\nline two\nadded on disk\n")
22 #expect(!session.save())
23 #expect(session.conflict != nil)
24 let versions = try #require(session.conflictVersions())
25 #expect(versions.base == "* A\nline one\nline two\n" && versions.disk.contains("added on disk"))
26 session.mergeConflictWithMarkers()
27 #expect(session.conflict == nil)
28 #expect(session.document?.text == "* A\n<<<<<<< yours\nline ONE mine\n=======\nline one disk\n>>>>>>> disk\nline two\nadded on disk\n")
29 #expect(session.save())
30 #expect(try String(contentsOf: file, encoding: .utf8) == session.document?.text)
31 // The buffer as it was is kept, and restoring it works as an edit.
32 let kept = session.recoveryVersions()
33 #expect(kept.contains { $0.label == "local" })
34 let local = try #require(kept.first { session.recoveryText($0.url) == "* A\nline ONE mine\nline two\n" })
35 #expect(session.restore(local.url) == nil)
36 #expect(session.document?.text == "* A\nline ONE mine\nline two\n")
37 }
38
39 @Test func resolvesSyncthingCopies() throws {
40 let notes = try Folder()
41 let file = try notes.write("n.org", "* N\nshared\nmine\n")
42 let first = try notes.write("n.sync-conflict-20261005-101010-ABCD123.org", "* N\nshared\ntheirs\n")
43 try notes.write("n.sync-conflict-20261005-121212-ZZZZ999.org", "* N\ncopy two\n")
44 try notes.write("other.sync-conflict-20261005-101010-ABCD123.org", "x\n")
45 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
46 session.open(file.path)
47 #expect(session.syncConflictCopies().map(\.lastPathComponent) == ["n.sync-conflict-20261005-101010-ABCD123.org", "n.sync-conflict-20261005-121212-ZZZZ999.org"])
48 #expect(session.resolve(copy: first, .merge) == nil)
49 #expect(session.document?.text == "* N\nshared\n<<<<<<< n.org\nmine\n=======\ntheirs\n>>>>>>> n.sync-conflict-20261005-101010-ABCD123.org\n")
50 #expect(!FileManager.default.fileExists(atPath: first.path))
51 let second = session.syncConflictCopies()[0]
52 #expect(session.resolve(copy: second, .useCopy) == nil)
53 #expect(session.document?.text == "* N\ncopy two\n")
54 #expect(session.syncConflictCopies().isEmpty)
55 #expect(session.recoveryVersions().filter { $0.label == "sync-conflict" }.count == 2)
56 }
57}
Tests/OrgDocumentTests/DiffTests.swift added +19
@@ -0,0 +1,19 @@
1import Testing
2@testable import OrgDocument
3
4struct DiffTests {
5 @Test func diffsLines() {
6 let diff = lineDiff(old: "a\nb\nc\n", new: "a\nB\nc\nd\n")
7 #expect(diff == [
8 DiffLine(kind: .same, text: "a\n"), DiffLine(kind: .removed, text: "b\n"), DiffLine(kind: .added, text: "B\n"),
9 DiffLine(kind: .same, text: "c\n"), DiffLine(kind: .added, text: "d\n"),
10 ])
11 }
12
13 @Test func mergesWithMarkers() {
14 let merged = mergeWithMarkers(base: "a\nb\nc\nd\n", ours: "a\nB1\nc\nd\nmine\n", theirs: "x\na\nB2\nc\nd\n", oursLabel: "mine", theirsLabel: "disk")
15 #expect(merged == "x\na\n<<<<<<< mine\nB1\n=======\nB2\n>>>>>>> disk\nc\nd\nmine\n")
16 let twoWay = mergeWithMarkers(base: nil, ours: "a\nb\n", theirs: "a\nc", oursLabel: "file", theirsLabel: "copy")
17 #expect(twoWay == "a\n<<<<<<< file\nb\n=======\nc\n>>>>>>> copy\n")
18 }
19}