krz/orgstar

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

Sources/OrgstarMobile/VersionScreens.swift

main
orgstar/Sources/OrgstarMobile/VersionScreens.swift history · blame · raw

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