gitbay/Views/Repos/RepoMirrorDomainViews.swift
383 lines · 15747 bytes
7 symbols in this file
1import SwiftUI
2
3/// A repository's mirrors: where each pushes to or pulls from, how its
4/// last sync went, a way to sync now, add and remove.
5struct MirrorsView: View {
6
7 @State private var model: MirrorsViewModel
8 @State private var adding = false
9 @State private var removing: Mirror?
10 @State private var scheduled = false
11
12 init(client: GitbayClient, repo: String) {
13 _model = State(initialValue: MirrorsViewModel(client: client, repoPath: repo))
14 }
15
16 var body: some View {
17 List {
18 if let error = model.actionError, !adding {
19 Section { GBNotice(error, .gbWarn) }
20 } else if scheduled {
21 Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) }
22 }
23 Section {
24 ForEach(model.state.value ?? []) { mirror in
25 row(mirror)
26 .swipeActions {
27 Button("Remove", role: .destructive) {
28 scheduled = false
29 removing = mirror
30 }
31 .disabled(model.working)
32 }
33 }
34 } footer: {
35 if model.state.value != nil {
36 Text("A push mirror sends every push here to the remote. A pull mirror fetches from it, and pushes here are refused.")
37 }
38 }
39 if model.state.value != nil {
40 Section {
41 Button {
42 Task { scheduled = await model.sync() }
43 } label: {
44 Label("Sync now", systemImage: "arrow.triangle.2.circlepath")
45 .font(.gbSans(.subheadline))
46 }
47 .disabled(model.working)
48 .accessibilityIdentifier("mirror-sync-button")
49 }
50 }
51 }
52 .overlay { LoadStateOverlay(state: model.state) }
53 .navigationTitle("Mirrors")
54 .navigationBarTitleDisplayMode(.inline)
55 .toolbar {
56 ToolbarItem(placement: .topBarTrailing) {
57 Button {
58 scheduled = false
59 adding = true
60 } label: { Image(systemName: "plus") }
61 .accessibilityLabel("Add Mirror")
62 .disabled(model.working)
63 .accessibilityIdentifier("mirror-add-button")
64 }
65 }
66 .sheet(isPresented: $adding) {
67 MirrorAddSheet(model: model) { adding = false }
68 }
69 .confirmationDialog(
70 "Remove the mirror \(removing?.isPull == true ? "from" : "to") \(removing?.url ?? "")?",
71 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
72 titleVisibility: .visible
73 ) {
74 Button("Remove", role: .destructive) {
75 if let mirror = removing { Task { await model.remove(id: mirror.id) } }
76 removing = nil
77 }
78 Button("Cancel", role: .cancel) {}
79 }
80 .task { await model.load() }
81 .refreshable {
82 scheduled = false
83 await model.load()
84 }
85 }
86
87 private func row(_ mirror: Mirror) -> some View {
88 VStack(alignment: .leading, spacing: 3) {
89 Text(mirror.url)
90 .font(.gbMono(.caption))
91 .lineLimit(2)
92 HStack(spacing: 6) {
93 GBChip(mirror.isPull ? "pull" : "push", .secondary)
94 if let username = mirror.username {
95 Text("as \(username)")
96 }
97 if mirror.pending {
98 Text("sync pending")
99 } else if let synced = mirror.lastSync {
100 Text("synced \(synced.formatted(.relative(presentation: .named)))")
101 }
102 }
103 .font(.gbSans(.caption2))
104 .foregroundStyle(.secondary)
105 if let error = mirror.lastError, !error.isEmpty {
106 Text(error)
107 .font(.gbSans(.caption2))
108 .foregroundStyle(Color.gbBad)
109 }
110 }
111 .padding(.vertical, 2)
112 .accessibilityElement(children: .combine)
113 }
114}
115
116private struct MirrorAddSheet: View {
117 let model: MirrorsViewModel
118 let onDone: () -> Void
119
120 @State private var url = ""
121 @State private var pull = false
122 @State private var username = ""
123 @State private var token = ""
124 @State private var attempted = false
125
126 var body: some View {
127 NavigationStack {
128 Form {
129 Section {
130 TextField("https://…", text: $url)
131 .autocorrectionDisabled()
132 .textInputAutocapitalization(.never)
133 .keyboardType(.URL)
134 .accessibilityIdentifier("mirror-url")
135 Picker("Direction", selection: $pull) {
136 Text("Push").tag(false)
137 Text("Pull").tag(true)
138 }
139 .pickerStyle(.segmented)
140 .accessibilityIdentifier("mirror-direction")
141 } footer: {
142 Text(pull
143 ? "Pulls from the remote. Pushes to this repository are refused while the mirror exists."
144 : "Pushes here are sent on to the remote.")
145 }
146 Section {
147 TextField("Username (optional)", text: $username)
148 .autocorrectionDisabled()
149 .textInputAutocapitalization(.never)
150 SecureField("Token (optional)", text: $token)
151 .autocorrectionDisabled()
152 .textInputAutocapitalization(.never)
153 } footer: {
154 Text("For a remote that needs credentials. The token is stored for syncs and not shown again.")
155 }
156 if attempted, let error = model.actionError {
157 Section { GBNotice(error, .gbWarn) }
158 }
159 }
160 .navigationTitle("Add Mirror")
161 .navigationBarTitleDisplayMode(.inline)
162 .toolbar {
163 ToolbarItem(placement: .cancellationAction) {
164 Button("Cancel") { onDone() }.disabled(model.working)
165 }
166 ToolbarItem(placement: .confirmationAction) {
167 if model.working {
168 ProgressView()
169 } else {
170 Button("Add") {
171 Task {
172 attempted = true
173 guard await model.add(url: url, pull: pull, username: username, token: token) else { return }
174 // A secure field that still holds text as the sheet
175 // goes makes iOS offer to save it as a password.
176 token = ""
177 await Task.yield()
178 onDone()
179 }
180 }
181 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
182 .accessibilityIdentifier("mirror-submit")
183 }
184 }
185 }
186 .interactiveDismissDisabled(model.working)
187 }
188 }
189}
190
191/// A repository's custom pages domains: each claim, verified or waiting
192/// on its DNS record, with verify and remove.
193struct PageDomainsView: View {
194
195 @State private var model: PageDomainsViewModel
196 @State private var adding = false
197 @State private var removing: PageDomain?
198 @State private var verified: String?
199
200 init(client: GitbayClient, repo: String) {
201 _model = State(initialValue: PageDomainsViewModel(client: client, repoPath: repo))
202 }
203
204 var body: some View {
205 List {
206 if let error = model.actionError, !adding {
207 Section { GBNotice(error, .gbWarn) }
208 } else if let verified {
209 Section {
210 GBNotice("\(verified) is verified. Point its A and AAAA records at this server.", .gbOK)
211 }
212 }
213 Section {
214 ForEach(model.state.value ?? []) { claim in
215 VStack(alignment: .leading, spacing: 3) {
216 Text(claim.domain).font(.gbMono(.subheadline))
217 HStack(spacing: 6) {
218 GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary)
219 if let at = claim.verifiedAt {
220 Text("since \(at.formatted(date: .abbreviated, time: .omitted))")
221 .font(.gbSans(.caption2))
222 .foregroundStyle(.secondary)
223 }
224 }
225 }
226 .padding(.vertical, 2)
227 .accessibilityElement(children: .combine)
228 .swipeActions {
229 Button("Remove", role: .destructive) {
230 verified = nil
231 removing = claim
232 }
233 .disabled(model.working)
234 if claim.isPending {
235 Button("Verify") { verify(claim.domain) }
236 .tint(.gbAccent)
237 .disabled(model.working)
238 }
239 }
240 }
241 } footer: {
242 if model.state.value != nil {
243 Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.")
244 }
245 }
246 }
247 .overlay { LoadStateOverlay(state: model.state) }
248 .navigationTitle("Custom Domains")
249 .navigationBarTitleDisplayMode(.inline)
250 .toolbar {
251 ToolbarItem(placement: .topBarTrailing) {
252 Button {
253 verified = nil
254 adding = true
255 } label: { Image(systemName: "plus") }
256 .accessibilityLabel("Claim Domain")
257 .disabled(model.working)
258 .accessibilityIdentifier("domain-add-button")
259 }
260 }
261 .sheet(isPresented: $adding) {
262 DomainClaimSheet(model: model) { domain in
263 adding = false
264 if let domain { verified = domain }
265 }
266 }
267 .confirmationDialog(
268 "Remove \(removing?.domain ?? "")? It stops serving this repository's pages.",
269 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
270 titleVisibility: .visible
271 ) {
272 Button("Remove", role: .destructive) {
273 if let claim = removing { Task { await model.remove(claim.domain) } }
274 removing = nil
275 }
276 Button("Cancel", role: .cancel) {}
277 }
278 .task { await model.load() }
279 .refreshable {
280 verified = nil
281 await model.load()
282 }
283 }
284
285 private func verify(_ domain: String) {
286 verified = nil
287 Task { if await model.verify(domain) { verified = domain } }
288 }
289}
290
291/// Claims a domain, then shows the TXT record to create and a way to
292/// verify it once it is in place. The record is shown only here.
293private struct DomainClaimSheet: View {
294 let model: PageDomainsViewModel
295 /// The domain when it was verified from here, else nil.
296 let onDone: (String?) -> Void
297
298 @State private var domain = ""
299 @State private var challenge: DomainChallenge?
300 @State private var attempted = false
301
302 var body: some View {
303 NavigationStack {
304 Form {
305 if let challenge {
306 Section {
307 record("Name", challenge.name, id: "domain-copy-name")
308 LabeledContent("Type", value: "TXT")
309 record("Value", challenge.value, id: "domain-copy-value")
310 } header: {
311 Text("Create this DNS record")
312 } footer: {
313 Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.")
314 }
315 } else {
316 Section {
317 TextField("docs.example.com", text: $domain)
318 .autocorrectionDisabled()
319 .textInputAutocapitalization(.never)
320 .keyboardType(.URL)
321 .accessibilityIdentifier("domain-name")
322 } footer: {
323 Text("A domain you control. Claiming it gives you a DNS record that proves it.")
324 }
325 }
326 if attempted, let error = model.actionError {
327 Section { GBNotice(error, .gbWarn) }
328 }
329 }
330 .navigationTitle(challenge == nil ? "Claim Domain" : challenge!.domain)
331 .navigationBarTitleDisplayMode(.inline)
332 .toolbar {
333 ToolbarItem(placement: .cancellationAction) {
334 Button(challenge == nil ? "Cancel" : "Done") { onDone(nil) }
335 .disabled(model.working)
336 .accessibilityIdentifier("domain-done")
337 }
338 ToolbarItem(placement: .confirmationAction) {
339 if model.working {
340 ProgressView()
341 } else if let challenge {
342 Button("Verify") {
343 Task {
344 attempted = true
345 if await model.verify(challenge.domain) { onDone(challenge.domain) }
346 }
347 }
348 .accessibilityIdentifier("domain-verify")
349 } else {
350 Button("Claim") {
351 Task {
352 attempted = true
353 challenge = await model.add(domain)
354 }
355 }
356 .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty)
357 .accessibilityIdentifier("domain-submit")
358 }
359 }
360 }
361 // The record cannot be fetched again, so it closes only by Done.
362 .interactiveDismissDisabled(model.working || challenge != nil)
363 }
364 }
365
366 private func record(_ label: String, _ text: String, id: String) -> some View {
367 HStack(alignment: .firstTextBaseline) {
368 VStack(alignment: .leading, spacing: 2) {
369 Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary)
370 Text(text).font(.gbMono(.caption)).textSelection(.enabled)
371 }
372 Spacer()
373 Button {
374 UIPasteboard.general.string = text
375 } label: {
376 Image(systemName: "doc.on.doc")
377 }
378 .buttonStyle(.borderless)
379 .accessibilityLabel("Copy \(label)")
380 .accessibilityIdentifier(id)
381 }
382 }
383}