Sources/OrgstarMobile/VersionScreens.swift
221 lines · 8547 bytes
10 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgDocument
4import SwiftUI
5import UIKit
6
7/// Lines removed and added between two versions, with a few lines of context around each change.
8struct DiffView: View {
9 let lines: [DiffLine]
10 var context = 3
11
12 init(old: String, new: String) {
13 lines = lineDiff(old: old, new: new)
14 }
15
16 /// Lines to show, nil standing for a run of unchanged lines left out.
17 private var shown: [DiffLine?] {
18 let changed = lines.indices.filter { lines[$0].kind != .same }
19 guard !changed.isEmpty else { return [] }
20 var keep = Set<Int>()
21 for i in changed { keep.formUnion(max(0, i - context)...min(lines.count - 1, i + context)) }
22 var out: [DiffLine?] = []
23 var skipped = false
24 for i in lines.indices {
25 if keep.contains(i) {
26 out.append(lines[i])
27 skipped = false
28 } else if !skipped {
29 out.append(nil)
30 skipped = true
31 }
32 }
33 return out
34 }
35
36 var body: some View {
37 let shown = shown
38 ScrollView([.vertical, .horizontal]) {
39 VStack(alignment: .leading, spacing: 0) {
40 if shown.isEmpty { Text("No differences").foregroundStyle(.secondary).padding(8) }
41 ForEach(shown.indices, id: \.self) { i in
42 if let line = shown[i] {
43 Text(prefix(line.kind) + line.text.trimmingCharacters(in: .newlines))
44 .frame(maxWidth: .infinity, alignment: .leading)
45 .background(background(line.kind))
46 } else {
47 Text("⋯").foregroundStyle(.secondary)
48 }
49 }
50 }
51 .font(.system(.footnote, design: .monospaced))
52 .textSelection(.enabled)
53 .padding(8)
54 }
55 .defaultScrollAnchor(.topLeading)
56 .background(Color(uiColor: .secondarySystemBackground))
57 }
58
59 private func prefix(_ kind: DiffLine.Kind) -> String {
60 switch kind {
61 case .same: " "
62 case .removed: "- "
63 case .added: "+ "
64 }
65 }
66
67 private func background(_ kind: DiffLine.Kind) -> Color {
68 switch kind {
69 case .same: .clear
70 case .removed: .red.opacity(0.18)
71 case .added: .green.opacity(0.18)
72 }
73 }
74}
75
76/// The buffer and the file on disk changed differently: what differs, and how to settle it.
77struct ConflictSheet: View {
78 let session: DocumentSession
79 @Environment(\.dismiss) private var dismiss
80
81 var body: some View {
82 NavigationStack {
83 VStack(alignment: .leading, spacing: 10) {
84 Text((session.conflict ?? "") + " Lines marked - are on disk, + in your version. The version you don't keep is saved in the recovery folder.")
85 .font(.callout)
86 .foregroundStyle(.secondary)
87 .fixedSize(horizontal: false, vertical: true)
88 if let versions = session.conflictVersions() {
89 DiffView(old: versions.disk, new: versions.mine)
90 }
91 Button("Keep Mine") { session.keepMine(); dismiss() }
92 .buttonStyle(.borderedProminent)
93 .frame(maxWidth: .infinity)
94 HStack {
95 Button("Use Disk Version") { session.useDiskVersion(); dismiss() }
96 Spacer()
97 Button("Merge with Markers") { session.mergeConflictWithMarkers(); dismiss() }
98 }
99 .buttonStyle(.bordered)
100 }
101 .padding()
102 .navigationTitle("Conflicting Changes")
103 .navigationBarTitleDisplayMode(.inline)
104 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Later") { dismiss() } } }
105 }
106 }
107}
108
109/// Syncthing's conflict copies of the open file: each against the file, and what to keep.
110struct SyncConflictSheet: View {
111 let session: DocumentSession
112 let report: (String) -> Void
113 @State private var copies: [URL] = []
114 @State private var selected: URL?
115 @Environment(\.dismiss) private var dismiss
116
117 var body: some View {
118 NavigationStack {
119 VStack(alignment: .leading, spacing: 10) {
120 if copies.isEmpty {
121 Text("No conflict copies of this file.").foregroundStyle(.secondary)
122 Spacer()
123 } else {
124 Picker("Copy", selection: $selected) {
125 ForEach(copies, id: \.self) { Text($0.lastPathComponent).tag(Optional($0)) }
126 }
127 Text("Lines marked - are in the file, + in the copy. The copy goes to the recovery folder when it's resolved.")
128 .font(.callout)
129 .foregroundStyle(.secondary)
130 if let selected, let copy = session.recoveryText(selected), let mine = session.document?.text {
131 DiffView(old: mine, new: copy)
132 }
133 if let selected {
134 HStack {
135 Button("Keep File") { resolve(selected, .keepFile) }
136 Spacer()
137 Button("Merge") { resolve(selected, .merge) }
138 Spacer()
139 Button("Use Copy") { resolve(selected, .useCopy) }
140 }
141 .buttonStyle(.bordered)
142 }
143 }
144 }
145 .padding()
146 .navigationTitle("Sync Conflict Copies")
147 .navigationBarTitleDisplayMode(.inline)
148 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
149 }
150 .onAppear(perform: reload)
151 }
152
153 private func reload() {
154 copies = session.syncConflictCopies()
155 if selected.map({ !copies.contains($0) }) ?? true { selected = copies.first }
156 }
157
158 private func resolve(_ copy: URL, _ resolution: DocumentSession.CopyResolution) {
159 if let message = session.resolve(copy: copy, resolution) { report(message) }
160 reload()
161 if copies.isEmpty { dismiss() }
162 }
163}
164
165/// Versions of the open file kept in the recovery folder, newest first; one against the buffer.
166struct RecoverySheet: View {
167 let session: DocumentSession
168 let report: (String) -> Void
169 @State private var versions: [(url: URL, date: Date, label: String)] = []
170 @Environment(\.dismiss) private var dismiss
171
172 var body: some View {
173 NavigationStack {
174 List(versions, id: \.url) { version in
175 NavigationLink {
176 detail(version.url)
177 } label: {
178 VStack(alignment: .leading) {
179 Text(version.date.formatted(date: .abbreviated, time: .standard))
180 Text(Self.describe(version.label)).font(.caption).foregroundStyle(.secondary)
181 }
182 }
183 }
184 .overlay {
185 if versions.isEmpty { ContentUnavailableView("No Kept Versions", systemImage: "clock.arrow.circlepath") }
186 }
187 .navigationTitle("Recovered Versions")
188 .navigationBarTitleDisplayMode(.inline)
189 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
190 }
191 .onAppear { versions = session.recoveryVersions() }
192 }
193
194 private func detail(_ version: URL) -> some View {
195 VStack(alignment: .leading, spacing: 10) {
196 Text("Lines marked - are in the buffer, + in the kept version.").font(.callout).foregroundStyle(.secondary)
197 if let kept = session.recoveryText(version), let mine = session.document?.text {
198 DiffView(old: mine, new: kept)
199 }
200 }
201 .padding()
202 .toolbar {
203 ToolbarItem(placement: .confirmationAction) {
204 Button("Restore") {
205 report(session.restore(version) ?? "Restored; undo brings back what was there.")
206 dismiss()
207 }
208 }
209 }
210 }
211
212 static func describe(_ label: String) -> String {
213 switch label {
214 case "local": "Your version, replaced"
215 case "external": "Disk version, replaced"
216 case "sync-conflict": "Sync conflict copy"
217 default: label
218 }
219 }
220}
221#endif