Sources/Orgstar/VersionViews.swift
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}