Commit 1d52ac3e82
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/EditorScreen.swift +33 −5
| @@ -16,7 +16,7 @@ struct EditorScreen: View { | |||
| 16 | @State private var editor: MobileEditor? | 16 | @State private var editor: MobileEditor? |
| 17 | @State private var prompt: PromptRequest? | 17 | @State private var prompt: PromptRequest? |
| 18 | @State private var message: String? | 18 | @State private var message: String? |
| 19 | @State private var showingPalette = false | 19 | @State private var sheet: Sheet? |
| 20 | @Environment(\.openURL) private var openURL | 20 | @Environment(\.openURL) private var openURL |
| 21 | 21 | ||
| 22 | struct PromptRequest: Identifiable { | 22 | struct PromptRequest: Identifiable { |
| @@ -25,10 +25,15 @@ struct EditorScreen: View { | |||
| 25 | let reply: (String?) -> Void | 25 | let reply: (String?) -> Void |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | enum Sheet: String, Identifiable { | ||
| 29 | case palette, conflict, syncCopies, recovery | ||
| 30 | var id: String { rawValue } | ||
| 31 | } | ||
| 32 | |||
| 28 | var body: some View { | 33 | var body: some View { |
| 29 | Group { | 34 | Group { |
| 30 | if let editor { | 35 | if let editor { |
| 31 | EditorTextView(editor: editor, palette: { showingPalette = true }) | 36 | EditorTextView(editor: editor, palette: { sheet = .palette }) |
| 32 | .ignoresSafeArea(.container, edges: .bottom) | 37 | .ignoresSafeArea(.container, edges: .bottom) |
| 33 | } else { | 38 | } else { |
| 34 | ProgressView() | 39 | ProgressView() |
| @@ -39,8 +44,15 @@ struct EditorScreen: View { | |||
| 39 | .toolbar(.hidden, for: .tabBar) | 44 | .toolbar(.hidden, for: .tabBar) |
| 40 | .toolbar { | 45 | .toolbar { |
| 41 | ToolbarItemGroup(placement: .primaryAction) { | 46 | ToolbarItemGroup(placement: .primaryAction) { |
| 47 | if session.conflict != nil { | ||
| 48 | Button("Conflict", systemImage: "exclamationmark.triangle") { sheet = .conflict } | ||
| 49 | } | ||
| 42 | Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } | 50 | Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } |
| 43 | Button("Commands", systemImage: "command") { showingPalette = true } | 51 | Menu("More", systemImage: "ellipsis.circle") { |
| 52 | Button("Commands", systemImage: "command") { sheet = .palette } | ||
| 53 | Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } | ||
| 54 | Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } | ||
| 55 | } | ||
| 44 | } | 56 | } |
| 45 | } | 57 | } |
| 46 | .safeAreaInset(edge: .top) { | 58 | .safeAreaInset(edge: .top) { |
| @@ -59,8 +71,19 @@ struct EditorScreen: View { | |||
| 59 | request.reply(answer) | 71 | request.reply(answer) |
| 60 | } | 72 | } |
| 61 | } | 73 | } |
| 62 | .sheet(isPresented: $showingPalette) { | 74 | .sheet(item: $sheet) { sheet in |
| 63 | if let editor { CommandPalette(editor: editor) } | 75 | switch sheet { |
| 76 | case .palette: if let editor { CommandPalette(editor: editor) } | ||
| 77 | case .conflict: ConflictSheet(session: session) | ||
| 78 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } | ||
| 79 | case .recovery: RecoverySheet(session: session) { message = $0 } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | .onChange(of: session.conflict) { _, conflict in | ||
| 83 | if conflict != nil, session.path == path { sheet = .conflict } | ||
| 84 | } | ||
| 85 | .onChange(of: session.lastError) { _, error in | ||
| 86 | if let error { message = error; session.lastError = nil } | ||
| 64 | } | 87 | } |
| 65 | .onAppear(perform: start) | 88 | .onAppear(perform: start) |
| 66 | .onChange(of: session.revealRequest?.id) { | 89 | .onChange(of: session.revealRequest?.id) { |
| @@ -92,6 +115,11 @@ struct EditorScreen: View { | |||
| 92 | } | 115 | } |
| 93 | if let editor { session.attach(editor) } | 116 | if let editor { session.attach(editor) } |
| 94 | if let offset { editor?.reveal(offset) } | 117 | if let offset { editor?.reveal(offset) } |
| 118 | if session.conflict != nil { | ||
| 119 | sheet = .conflict | ||
| 120 | } else if case let copies = session.syncConflictCopies().count, copies > 0 { | ||
| 121 | message = "\(copies) sync conflict \(copies == 1 ? "copy" : "copies") of this file; More › Sync Conflict Copies compares them." | ||
| 122 | } | ||
| 95 | } | 123 | } |
| 96 | } | 124 | } |
| 97 | 125 | ||
Sources/OrgstarMobile/VersionScreens.swift added +221
| @@ -0,0 +1,221 @@ | |||
| 1 | #if os(iOS) | ||
| 2 | import OrgApp | ||
| 3 | import OrgDocument | ||
| 4 | import SwiftUI | ||
| 5 | import UIKit | ||
| 6 | |||
| 7 | /// Lines removed and added between two versions, with a few lines of context around each change. | ||
| 8 | struct 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. | ||
| 77 | struct 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. | ||
| 110 | struct 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. | ||
| 166 | struct 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 | ||