krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.11.0: Hutch/Views/Repositories/RepositoryDeployKeysView.swift · raw
1import SwiftUI
2
3@Observable
4@MainActor
5final class RepositoryDeployKeysViewModel {
6 private(set) var keys: [RepositoryDeployKey] = []
7 private(set) var isLoading = false
8 private(set) var isSaving = false
9 private(set) var deletingRID: String?
10 var loadError: String?
11 var error: String?
12 var saveError: String?
13
14 let repositoryRid: String
15 private let service: RepositoryDeployKeyService
16
17 init(repositoryRid: String, service: RepositoryDeployKeyService) {
18 self.repositoryRid = repositoryRid
19 self.service = service
20 }
21
22 func load() async {
23 guard !isLoading else { return }
24 isLoading = true
25 loadError = nil
26 defer { isLoading = false }
27 do {
28 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
29 } catch {
30 if keys.isEmpty {
31 loadError = error.userFacingMessage
32 } else {
33 self.error = error.userFacingMessage
34 }
35 }
36 }
37
38 func addKey(publicKey: String, mode: AccessMode) async -> Bool {
39 let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
40 guard !trimmed.isEmpty, !isSaving else { return false }
41 isSaving = true
42 saveError = nil
43 defer { isSaving = false }
44 do {
45 try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
46 // The create response omits the key's fields, so reload the list.
47 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
48 return true
49 } catch {
50 saveError = error.userFacingMessage
51 return false
52 }
53 }
54
55 func deleteKey(_ key: RepositoryDeployKey) async {
56 guard deletingRID == nil else { return }
57 deletingRID = key.rid
58 error = nil
59 defer { deletingRID = nil }
60 do {
61 try await service.deleteDeployKey(rid: key.rid)
62 keys.removeAll { $0.rid == key.rid }
63 } catch {
64 self.error = error.userFacingMessage
65 }
66 }
67}
68
69struct RepositoryDeployKeysView: View {
70 let repository: RepositorySummary
71 let client: SRHTClient
72 var showsDoneButton = false
73
74 @Environment(\.dismiss) private var dismiss
75 @State private var viewModel: RepositoryDeployKeysViewModel?
76 @State private var showAddSheet = false
77 @State private var pendingDeletion: RepositoryDeployKey?
78
79 var body: some View {
80 Group {
81 if let viewModel {
82 content(viewModel)
83 } else {
84 SRHTLoadingStateView(message: "Loading deploy keys…")
85 }
86 }
87 .navigationTitle("Deploy Keys")
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 if showsDoneButton {
91 ToolbarItem(placement: .cancellationAction) {
92 Button("Done") { dismiss() }
93 }
94 }
95 if viewModel != nil {
96 ToolbarItem(placement: .topBarTrailing) {
97 Button {
98 showAddSheet = true
99 } label: {
100 Image(systemName: "plus")
101 }
102 .accessibilityLabel("Add deploy key")
103 }
104 }
105 }
106 .task {
107 if viewModel == nil {
108 let vm = RepositoryDeployKeysViewModel(
109 repositoryRid: repository.rid,
110 service: RepositoryDeployKeyService(client: client)
111 )
112 viewModel = vm
113 await vm.load()
114 }
115 }
116 }
117
118 @ViewBuilder
119 private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
120 Group {
121 if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
122 SRHTLoadingStateView(message: "Loading deploy keys…")
123 } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
124 SRHTErrorStateView(
125 title: "Couldn't Load Deploy Keys",
126 message: loadError,
127 retryAction: { await viewModel.load() }
128 )
129 } else {
130 List {
131 if viewModel.keys.isEmpty {
132 Section {
133 ContentUnavailableView(
134 "No Deploy Keys",
135 systemImage: "key",
136 description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
137 )
138 .themedRow()
139 }
140 } else {
141 Section {
142 ForEach(viewModel.keys) { key in
143 DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
144 .themedRow()
145 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146 Button(role: .destructive) {
147 pendingDeletion = key
148 } label: {
149 Label("Delete", systemImage: "trash")
150 }
151 }
152 }
153 } footer: {
154 Text("Deploy keys are SSH keys scoped to this repository only.")
155 }
156 }
157 }
158 .themedList()
159 .refreshable { await viewModel.load() }
160 }
161 }
162 .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
163 .confirmationDialog(
164 "Delete this deploy key?",
165 isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
166 titleVisibility: .visible
167 ) {
168 Button("Cancel", role: .cancel) { pendingDeletion = nil }
169 Button("Delete", role: .destructive) {
170 if let key = pendingDeletion {
171 pendingDeletion = nil
172 Task { await viewModel.deleteKey(key) }
173 }
174 }
175 } message: {
176 Text("This revokes the key's access to \(repository.name). This cannot be undone.")
177 }
178 .sheet(isPresented: $showAddSheet) {
179 AddDeployKeyView(viewModel: viewModel)
180 }
181 }
182}
183
184private struct DeployKeyRow: View {
185 let key: RepositoryDeployKey
186 let isDeleting: Bool
187
188 var body: some View {
189 HStack(spacing: 12) {
190 VStack(alignment: .leading, spacing: 3) {
191 Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
192 .font(.body)
193 .lineLimit(1)
194 Text(key.fingerprintSHA256)
195 .font(.caption.monospaced())
196 .foregroundStyle(.secondary)
197 .lineLimit(1)
198 .truncationMode(.middle)
199 }
200 Spacer()
201 if isDeleting {
202 ProgressView().controlSize(.small)
203 } else {
204 Text(key.access.displayName)
205 .font(.caption.weight(.medium))
206 .foregroundStyle(.secondary)
207 }
208 }
209 .padding(.vertical, 2)
210 }
211}
212
213private struct AddDeployKeyView: View {
214 let viewModel: RepositoryDeployKeysViewModel
215
216 @Environment(\.dismiss) private var dismiss
217 @State private var publicKey = ""
218 @State private var mode: AccessMode = .ro
219
220 var body: some View {
221 NavigationStack {
222 Form {
223 Section("SSH Public Key") {
224 TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
225 .lineLimit(3...8)
226 .textInputAutocapitalization(.never)
227 .autocorrectionDisabled()
228 .font(.body.monospaced())
229 .themedRow()
230 }
231 Section {
232 Picker("Access", selection: $mode) {
233 Text("Read Only").tag(AccessMode.ro)
234 Text("Read/Write").tag(AccessMode.rw)
235 }
236 .themedRow()
237 } footer: {
238 Text("Read/Write lets the key push to this repository.")
239 }
240 if let saveError = viewModel.saveError, !saveError.isEmpty {
241 Section {
242 Text(saveError).foregroundStyle(.red).themedRow()
243 }
244 }
245 }
246 .themedList()
247 .navigationTitle("Add Deploy Key")
248 .navigationBarTitleDisplayMode(.inline)
249 .toolbar {
250 ToolbarItem(placement: .cancellationAction) {
251 Button("Cancel") { dismiss() }
252 }
253 ToolbarItem(placement: .confirmationAction) {
254 Button {
255 Task {
256 if await viewModel.addKey(publicKey: publicKey, mode: mode) {
257 dismiss()
258 }
259 }
260 } label: {
261 if viewModel.isSaving {
262 ProgressView().controlSize(.small)
263 } else {
264 Text("Add")
265 }
266 }
267 .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
268 }
269 }
270 }
271 }
272}