Commit 71333253c9
Verified · cmc
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. | ||
| 178 | public 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`. | ||
| 185 | public 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. | ||
| 205 | public 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 { | |||
| 879 | enum Panel: Identifiable { | 892 | enum 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 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgApp | ||
| 3 | import OrgDocument | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// Lines removed and added between two versions, with a few lines of context around each change. | ||
| 7 | struct 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. | ||
| 76 | struct 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. | ||
| 104 | struct 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. | ||
| 154 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import OrgDocument | ||
| 4 | import Testing | ||
| 5 | @testable import OrgApp | ||
| 6 | |||
| 7 | @MainActor | ||
| 8 | struct 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 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import OrgDocument | ||
| 3 | |||
| 4 | struct 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 | } | ||