krz/orgstar

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

Sources/Orgstar/VersionViews.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/Orgstar/VersionViews.swift history · blame · raw

210 lines · 8230 bytes

  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}