diff options
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/Networking/RepositoryDeployKeyService.swift | 100 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDeployKeysView.swift | 272 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 16 |
3 files changed, 388 insertions, 0 deletions
diff --git a/Hutch/Networking/RepositoryDeployKeyService.swift b/Hutch/Networking/RepositoryDeployKeyService.swift new file mode 100644 index 0000000..39e2da1 --- /dev/null +++ b/Hutch/Networking/RepositoryDeployKeyService.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`). +struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable { + let rid: String + let keyType: String + let fingerprintSHA256: String + let comment: String? + let access: AccessMode + + var id: String { rid } +} + +private struct DeployKeysQueryResponse: Decodable, Sendable { + let repository: DeployKeysRepository? +} + +private struct DeployKeysRepository: Decodable, Sendable { + let deployKeys: DeployKeysPage +} + +private struct DeployKeysPage: Decodable, Sendable { + let results: [RepositoryDeployKey] +} + +/// `createDeployKey`'s response returns an empty `access` (the stored value is +/// correct — the list query reports it), so we select only `rid` here and let +/// callers reload rather than decode the partial key. +private struct CreateDeployKeyResponse: Decodable, Sendable {} + +/// Delete returns the removed key; only success matters here. +private struct DeleteDeployKeyResponse: Decodable, Sendable {} + +/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`), +/// owner-only, alongside repository ACLs. +struct RepositoryDeployKeyService: Sendable { + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] { + let response = try await client.execute( + service: .git, + query: Self.deployKeysQuery, + variables: ["rid": repositoryRid], + responseType: DeployKeysQueryResponse.self + ) + return response.repository?.deployKeys.results ?? [] + } + + func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.createDeployKeyMutation, + variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key], + responseType: CreateDeployKeyResponse.self + ) + } + + func deleteDeployKey(rid: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.deleteDeployKeyMutation, + variables: ["rid": rid], + responseType: DeleteDeployKeyResponse.self + ) + } +} + +private extension RepositoryDeployKeyService { + static let deployKeysQuery = """ + query repositoryDeployKeys($rid: ID!) { + repository(rid: $rid) { + deployKeys { + results { + rid + keyType + fingerprintSHA256 + comment + access + } + } + } + } + """ + + static let createDeployKeyMutation = """ + mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) { + createDeployKey(repo: $repo, mode: $mode, key: $key) { rid } + } + """ + + static let deleteDeployKeyMutation = """ + mutation deleteDeployKey($rid: ID!) { + deleteDeployKey(rid: $rid) { rid } + } + """ +} 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") |
