diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 16:35:16 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-08-07 16:35:16 -0500 |
| commit | 9671eac8eeb9d06e25563f51bf81889b5537feeb (patch) | |
| tree | 6d943fc410b37d3dc7dfec72406688dbd97ac724 /Hutch/Views | |
| parent | 18b1c475983ae9e6a52fcedb2e2992193130035c (diff) | |
| parent | 07156180789b02d4f22a87f22a37c912e7543190 (diff) | |
| download | hutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.tar.gz hutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.tar.bz2 hutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.zip | |
Merge pull request #36 from krazywarez/deploy-keysv3.10.0
Deploy keys: manage git.sr.ht repository deploy keys (v3.10.0)
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDeployKeysView.swift | 272 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 16 |
2 files changed, 288 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift new file mode 100644 index 0000000..71e252b --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +@Observable +@MainActor +final class RepositoryDeployKeysViewModel { + private(set) var keys: [RepositoryDeployKey] = [] + private(set) var isLoading = false + private(set) var isSaving = false + private(set) var deletingRID: String? + var loadError: String? + var error: String? + var saveError: String? + + let repositoryRid: String + private let service: RepositoryDeployKeyService + + init(repositoryRid: String, service: RepositoryDeployKeyService) { + self.repositoryRid = repositoryRid + self.service = service + } + + func load() async { + guard !isLoading else { return } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + } catch { + if keys.isEmpty { + loadError = error.userFacingMessage + } else { + self.error = error.userFacingMessage + } + } + } + + func addKey(publicKey: String, mode: AccessMode) async -> Bool { + let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isSaving else { return false } + isSaving = true + saveError = nil + defer { isSaving = false } + do { + try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed) + // The create response omits the key's fields, so reload the list. + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + return true + } catch { + saveError = error.userFacingMessage + return false + } + } + + func deleteKey(_ key: RepositoryDeployKey) async { + guard deletingRID == nil else { return } + deletingRID = key.rid + error = nil + defer { deletingRID = nil } + do { + try await service.deleteDeployKey(rid: key.rid) + keys.removeAll { $0.rid == key.rid } + } catch { + self.error = error.userFacingMessage + } + } +} + +struct RepositoryDeployKeysView: View { + let repository: RepositorySummary + let client: SRHTClient + var showsDoneButton = false + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: RepositoryDeployKeysViewModel? + @State private var showAddSheet = false + @State private var pendingDeletion: RepositoryDeployKey? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading deploy keys…") + } + } + .navigationTitle("Deploy Keys") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add deploy key") + } + } + } + .task { + if viewModel == nil { + let vm = RepositoryDeployKeysViewModel( + repositoryRid: repository.rid, + service: RepositoryDeployKeyService(client: client) + ) + viewModel = vm + await vm.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View { + Group { + if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil { + SRHTLoadingStateView(message: "Loading deploy keys…") + } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Deploy Keys", + message: loadError, + retryAction: { await viewModel.load() } + ) + } else { + List { + if viewModel.keys.isEmpty { + Section { + ContentUnavailableView( + "No Deploy Keys", + systemImage: "key", + description: Text("Add an SSH public key to grant this repository read or read/write access for automation.") + ) + .themedRow() + } + } else { + Section { + ForEach(viewModel.keys) { key in + DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid) + .themedRow() + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingDeletion = key + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } footer: { + Text("Deploy keys are SSH keys scoped to this repository only.") + } + } + } + .themedList() + .refreshable { await viewModel.load() } + } + } + .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 })) + .confirmationDialog( + "Delete this deploy key?", + isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible + ) { + Button("Cancel", role: .cancel) { pendingDeletion = nil } + Button("Delete", role: .destructive) { + if let key = pendingDeletion { + pendingDeletion = nil + Task { await viewModel.deleteKey(key) } + } + } + } message: { + Text("This revokes the key's access to \(repository.name). This cannot be undone.") + } + .sheet(isPresented: $showAddSheet) { + AddDeployKeyView(viewModel: viewModel) + } + } +} + +private struct DeployKeyRow: View { + let key: RepositoryDeployKey + let isDeleting: Bool + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(key.comment?.isEmpty == false ? key.comment! : key.keyType) + .font(.body) + .lineLimit(1) + Text(key.fingerprintSHA256) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + if isDeleting { + ProgressView().controlSize(.small) + } else { + Text(key.access.displayName) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +private struct AddDeployKeyView: View { + let viewModel: RepositoryDeployKeysViewModel + + @Environment(\.dismiss) private var dismiss + @State private var publicKey = "" + @State private var mode: AccessMode = .ro + + var body: some View { + NavigationStack { + Form { + Section("SSH Public Key") { + TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical) + .lineLimit(3...8) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.body.monospaced()) + .themedRow() + } + Section { + Picker("Access", selection: $mode) { + Text("Read Only").tag(AccessMode.ro) + Text("Read/Write").tag(AccessMode.rw) + } + .themedRow() + } footer: { + Text("Read/Write lets the key push to this repository.") + } + if let saveError = viewModel.saveError, !saveError.isEmpty { + Section { + Text(saveError).foregroundStyle(.red).themedRow() + } + } + } + .themedList() + .navigationTitle("Add Deploy Key") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if await viewModel.addKey(publicKey: publicKey, mode: mode) { + dismiss() + } + } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text("Add") + } + } + .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving) + } + } + } + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 8f466ba..a4ac25e 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -12,6 +12,7 @@ struct RepositoryDetailView: View { @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary @State private var showSettings = false @State private var showACLs = false + @State private var showDeployKeys = false @State private var currentRepository: RepositorySummary @State private var pinChangeCount = 0 @@ -82,6 +83,15 @@ struct RepositoryDetailView: View { ) } } + .sheet(isPresented: $showDeployKeys) { + NavigationStack { + RepositoryDeployKeysView( + repository: currentRepository, + client: appState.client, + showsDoneButton: true + ) + } + } .task { if viewModel == nil { viewModel = RepositoryDetailViewModel( @@ -201,6 +211,12 @@ struct RepositoryDetailView: View { } Button { + showDeployKeys = true + } label: { + Label("Deploy Keys", systemImage: "key") + } + + Button { showSettings = true } label: { Label("Repository Settings", systemImage: "gear") |
