Commit f85e26bb97
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +12 −12
| @@ -597,7 +597,7 @@ | |||
| 597 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 597 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 598 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 598 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 599 | CODE_SIGN_STYLE = Automatic; | 599 | CODE_SIGN_STYLE = Automatic; |
| 600 | CURRENT_PROJECT_VERSION = 94; | 600 | CURRENT_PROJECT_VERSION = 95; |
| 601 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 601 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 602 | ENABLE_PREVIEWS = YES; | 602 | ENABLE_PREVIEWS = YES; |
| 603 | GENERATE_INFOPLIST_FILE = YES; | 603 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -614,7 +614,7 @@ | |||
| 614 | "$(inherited)", | 614 | "$(inherited)", |
| 615 | "@executable_path/Frameworks", | 615 | "@executable_path/Frameworks", |
| 616 | ); | 616 | ); |
| 617 | MARKETING_VERSION = 3.9.0; | 617 | MARKETING_VERSION = 3.10.0; |
| 618 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 618 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 619 | PRODUCT_NAME = "$(TARGET_NAME)"; | 619 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 620 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 620 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -634,7 +634,7 @@ | |||
| 634 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 634 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 635 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 635 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 636 | CODE_SIGN_STYLE = Automatic; | 636 | CODE_SIGN_STYLE = Automatic; |
| 637 | CURRENT_PROJECT_VERSION = 94; | 637 | CURRENT_PROJECT_VERSION = 95; |
| 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 639 | ENABLE_PREVIEWS = YES; | 639 | ENABLE_PREVIEWS = YES; |
| 640 | GENERATE_INFOPLIST_FILE = YES; | 640 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -651,7 +651,7 @@ | |||
| 651 | "$(inherited)", | 651 | "$(inherited)", |
| 652 | "@executable_path/Frameworks", | 652 | "@executable_path/Frameworks", |
| 653 | ); | 653 | ); |
| 654 | MARKETING_VERSION = 3.9.0; | 654 | MARKETING_VERSION = 3.10.0; |
| 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 656 | PRODUCT_NAME = "$(TARGET_NAME)"; | 656 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 657 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 657 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -714,7 +714,7 @@ | |||
| 714 | APPLICATION_EXTENSION_API_ONLY = YES; | 714 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 715 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 715 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 716 | CODE_SIGN_STYLE = Automatic; | 716 | CODE_SIGN_STYLE = Automatic; |
| 717 | CURRENT_PROJECT_VERSION = 94; | 717 | CURRENT_PROJECT_VERSION = 95; |
| 718 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 718 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 719 | GENERATE_INFOPLIST_FILE = NO; | 719 | GENERATE_INFOPLIST_FILE = NO; |
| 720 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 720 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -724,7 +724,7 @@ | |||
| 724 | "@executable_path/Frameworks", | 724 | "@executable_path/Frameworks", |
| 725 | "@executable_path/../../Frameworks", | 725 | "@executable_path/../../Frameworks", |
| 726 | ); | 726 | ); |
| 727 | MARKETING_VERSION = 3.9.0; | 727 | MARKETING_VERSION = 3.10.0; |
| 728 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 728 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 729 | PRODUCT_NAME = "$(TARGET_NAME)"; | 729 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 730 | SKIP_INSTALL = YES; | 730 | SKIP_INSTALL = YES; |
| @@ -743,7 +743,7 @@ | |||
| 743 | APPLICATION_EXTENSION_API_ONLY = YES; | 743 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 744 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 744 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 745 | CODE_SIGN_STYLE = Automatic; | 745 | CODE_SIGN_STYLE = Automatic; |
| 746 | CURRENT_PROJECT_VERSION = 94; | 746 | CURRENT_PROJECT_VERSION = 95; |
| 747 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 747 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 748 | GENERATE_INFOPLIST_FILE = NO; | 748 | GENERATE_INFOPLIST_FILE = NO; |
| 749 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 749 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -753,7 +753,7 @@ | |||
| 753 | "@executable_path/Frameworks", | 753 | "@executable_path/Frameworks", |
| 754 | "@executable_path/../../Frameworks", | 754 | "@executable_path/../../Frameworks", |
| 755 | ); | 755 | ); |
| 756 | MARKETING_VERSION = 3.9.0; | 756 | MARKETING_VERSION = 3.10.0; |
| 757 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 757 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 758 | PRODUCT_NAME = "$(TARGET_NAME)"; | 758 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 759 | SKIP_INSTALL = YES; | 759 | SKIP_INSTALL = YES; |
| @@ -772,7 +772,7 @@ | |||
| 772 | APPLICATION_EXTENSION_API_ONLY = YES; | 772 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 773 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 773 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 774 | CODE_SIGN_STYLE = Automatic; | 774 | CODE_SIGN_STYLE = Automatic; |
| 775 | CURRENT_PROJECT_VERSION = 94; | 775 | CURRENT_PROJECT_VERSION = 95; |
| 776 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 776 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 777 | GENERATE_INFOPLIST_FILE = NO; | 777 | GENERATE_INFOPLIST_FILE = NO; |
| 778 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; | 778 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; |
| @@ -782,7 +782,7 @@ | |||
| 782 | "@executable_path/Frameworks", | 782 | "@executable_path/Frameworks", |
| 783 | "@executable_path/../../Frameworks", | 783 | "@executable_path/../../Frameworks", |
| 784 | ); | 784 | ); |
| 785 | MARKETING_VERSION = 3.9.0; | 785 | MARKETING_VERSION = 3.10.0; |
| 786 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; | 786 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; |
| 787 | PRODUCT_NAME = "$(TARGET_NAME)"; | 787 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 788 | SKIP_INSTALL = YES; | 788 | SKIP_INSTALL = YES; |
| @@ -801,7 +801,7 @@ | |||
| 801 | APPLICATION_EXTENSION_API_ONLY = YES; | 801 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 802 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 802 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 803 | CODE_SIGN_STYLE = Automatic; | 803 | CODE_SIGN_STYLE = Automatic; |
| 804 | CURRENT_PROJECT_VERSION = 94; | 804 | CURRENT_PROJECT_VERSION = 95; |
| 805 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 805 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 806 | GENERATE_INFOPLIST_FILE = NO; | 806 | GENERATE_INFOPLIST_FILE = NO; |
| 807 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; | 807 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; |
| @@ -811,7 +811,7 @@ | |||
| 811 | "@executable_path/Frameworks", | 811 | "@executable_path/Frameworks", |
| 812 | "@executable_path/../../Frameworks", | 812 | "@executable_path/../../Frameworks", |
| 813 | ); | 813 | ); |
| 814 | MARKETING_VERSION = 3.9.0; | 814 | MARKETING_VERSION = 3.10.0; |
| 815 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; | 815 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; |
| 816 | PRODUCT_NAME = "$(TARGET_NAME)"; | 816 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 817 | SKIP_INSTALL = YES; | 817 | SKIP_INSTALL = YES; |
Hutch/Networking/RepositoryDeployKeyService.swift added +100
| @@ -0,0 +1,100 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`). | ||
| 4 | struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable { | ||
| 5 | let rid: String | ||
| 6 | let keyType: String | ||
| 7 | let fingerprintSHA256: String | ||
| 8 | let comment: String? | ||
| 9 | let access: AccessMode | ||
| 10 | |||
| 11 | var id: String { rid } | ||
| 12 | } | ||
| 13 | |||
| 14 | private struct DeployKeysQueryResponse: Decodable, Sendable { | ||
| 15 | let repository: DeployKeysRepository? | ||
| 16 | } | ||
| 17 | |||
| 18 | private struct DeployKeysRepository: Decodable, Sendable { | ||
| 19 | let deployKeys: DeployKeysPage | ||
| 20 | } | ||
| 21 | |||
| 22 | private struct DeployKeysPage: Decodable, Sendable { | ||
| 23 | let results: [RepositoryDeployKey] | ||
| 24 | } | ||
| 25 | |||
| 26 | /// `createDeployKey`'s response returns an empty `access` (the stored value is | ||
| 27 | /// correct — the list query reports it), so we select only `rid` here and let | ||
| 28 | /// callers reload rather than decode the partial key. | ||
| 29 | private struct CreateDeployKeyResponse: Decodable, Sendable {} | ||
| 30 | |||
| 31 | /// Delete returns the removed key; only success matters here. | ||
| 32 | private struct DeleteDeployKeyResponse: Decodable, Sendable {} | ||
| 33 | |||
| 34 | /// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`), | ||
| 35 | /// owner-only, alongside repository ACLs. | ||
| 36 | struct RepositoryDeployKeyService: Sendable { | ||
| 37 | private let client: SRHTClient | ||
| 38 | |||
| 39 | init(client: SRHTClient) { | ||
| 40 | self.client = client | ||
| 41 | } | ||
| 42 | |||
| 43 | func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] { | ||
| 44 | let response = try await client.execute( | ||
| 45 | service: .git, | ||
| 46 | query: Self.deployKeysQuery, | ||
| 47 | variables: ["rid": repositoryRid], | ||
| 48 | responseType: DeployKeysQueryResponse.self | ||
| 49 | ) | ||
| 50 | return response.repository?.deployKeys.results ?? [] | ||
| 51 | } | ||
| 52 | |||
| 53 | func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws { | ||
| 54 | _ = try await client.execute( | ||
| 55 | service: .git, | ||
| 56 | query: Self.createDeployKeyMutation, | ||
| 57 | variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key], | ||
| 58 | responseType: CreateDeployKeyResponse.self | ||
| 59 | ) | ||
| 60 | } | ||
| 61 | |||
| 62 | func deleteDeployKey(rid: String) async throws { | ||
| 63 | _ = try await client.execute( | ||
| 64 | service: .git, | ||
| 65 | query: Self.deleteDeployKeyMutation, | ||
| 66 | variables: ["rid": rid], | ||
| 67 | responseType: DeleteDeployKeyResponse.self | ||
| 68 | ) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | private extension RepositoryDeployKeyService { | ||
| 73 | static let deployKeysQuery = """ | ||
| 74 | query repositoryDeployKeys($rid: ID!) { | ||
| 75 | repository(rid: $rid) { | ||
| 76 | deployKeys { | ||
| 77 | results { | ||
| 78 | rid | ||
| 79 | keyType | ||
| 80 | fingerprintSHA256 | ||
| 81 | comment | ||
| 82 | access | ||
| 83 | } | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | """ | ||
| 88 | |||
| 89 | static let createDeployKeyMutation = """ | ||
| 90 | mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) { | ||
| 91 | createDeployKey(repo: $repo, mode: $mode, key: $key) { rid } | ||
| 92 | } | ||
| 93 | """ | ||
| 94 | |||
| 95 | static let deleteDeployKeyMutation = """ | ||
| 96 | mutation deleteDeployKey($rid: ID!) { | ||
| 97 | deleteDeployKey(rid: $rid) { rid } | ||
| 98 | } | ||
| 99 | """ | ||
| 100 | } | ||
Hutch/Views/Repositories/RepositoryDeployKeysView.swift added +272
| @@ -0,0 +1,272 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final 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 | |||
| 69 | struct 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 | |||
| 184 | private 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 | |||
| 213 | private 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 | } | ||
Hutch/Views/Repositories/RepositoryDetailView.swift +16
| @@ -12,6 +12,7 @@ struct RepositoryDetailView: View { | |||
| 12 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary | 12 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary |
| 13 | @State private var showSettings = false | 13 | @State private var showSettings = false |
| 14 | @State private var showACLs = false | 14 | @State private var showACLs = false |
| 15 | @State private var showDeployKeys = false | ||
| 15 | @State private var currentRepository: RepositorySummary | 16 | @State private var currentRepository: RepositorySummary |
| 16 | @State private var pinChangeCount = 0 | 17 | @State private var pinChangeCount = 0 |
| 17 | 18 | ||
| @@ -82,6 +83,15 @@ struct RepositoryDetailView: View { | |||
| 82 | ) | 83 | ) |
| 83 | } | 84 | } |
| 84 | } | 85 | } |
| 86 | .sheet(isPresented: $showDeployKeys) { | ||
| 87 | NavigationStack { | ||
| 88 | RepositoryDeployKeysView( | ||
| 89 | repository: currentRepository, | ||
| 90 | client: appState.client, | ||
| 91 | showsDoneButton: true | ||
| 92 | ) | ||
| 93 | } | ||
| 94 | } | ||
| 85 | .task { | 95 | .task { |
| 86 | if viewModel == nil { | 96 | if viewModel == nil { |
| 87 | viewModel = RepositoryDetailViewModel( | 97 | viewModel = RepositoryDetailViewModel( |
| @@ -200,6 +210,12 @@ struct RepositoryDetailView: View { | |||
| 200 | Label("Manage ACLs", systemImage: "person.2") | 210 | Label("Manage ACLs", systemImage: "person.2") |
| 201 | } | 211 | } |
| 202 | 212 | ||
| 213 | Button { | ||
| 214 | showDeployKeys = true | ||
| 215 | } label: { | ||
| 216 | Label("Deploy Keys", systemImage: "key") | ||
| 217 | } | ||
| 218 | |||
| 203 | Button { | 219 | Button { |
| 204 | showSettings = true | 220 | showSettings = true |
| 205 | } label: { | 221 | } label: { |
HutchTests/RepositoryDeployKeyTests.swift added +27
| @@ -0,0 +1,27 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct RepositoryDeployKeyTests { | ||
| 6 | @Test | ||
| 7 | func decodesDeployKey() throws { | ||
| 8 | let json = """ | ||
| 9 | { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" } | ||
| 10 | """ | ||
| 11 | let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) | ||
| 12 | #expect(key.rid == "k1") | ||
| 13 | #expect(key.id == "k1") | ||
| 14 | #expect(key.access == .rw) | ||
| 15 | #expect(key.comment == "laptop") | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test | ||
| 19 | func decodesDeployKeyWithNullComment() throws { | ||
| 20 | let json = """ | ||
| 21 | { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" } | ||
| 22 | """ | ||
| 23 | let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) | ||
| 24 | #expect(key.comment == nil) | ||
| 25 | #expect(key.access == .ro) | ||
| 26 | } | ||
| 27 | } | ||
ROADMAP.txt +11 −10
| @@ -144,7 +144,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*. | |||
| 144 | | v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | | 144 | | v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | |
| 145 | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | | 145 | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | |
| 146 | | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | | 146 | | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | |
| 147 | | v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map | | 147 | | v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map | |
| 148 | | v3.11.0 | Accessibility | Independent, device-verified | | 148 | | v3.11.0 | Accessibility | Independent, device-verified | |
| 149 | | v4.0.0 | Localization *with* translations | The only true re-presentation | | 149 | | v4.0.0 | Localization *with* translations | The only true re-presentation | |
| 150 | | — | Swift 6 language mode; cache reads | Internal; ride along, no tag | | 150 | | — | Swift 6 language mode; cache reads | Internal; ride along, no tag | |
| @@ -276,7 +276,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what | |||
| 276 | pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, | 276 | pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, |
| 277 | not just a green suite. | 277 | not just a green suite. |
| 278 | 278 | ||
| 279 | ### Ingest "What's cooking on SourceHut?" — v3.9.0 | 279 | ### Ingest "What's cooking on SourceHut?" — v3.10.0 |
| 280 | 280 | ||
| 281 | sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at | 281 | sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at |
| 282 | <https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and | 282 | <https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and |
| @@ -294,19 +294,20 @@ flags two openings: | |||
| 294 | - **hub.sr.ht gained a writable GraphQL API** for managing projects and project | 294 | - **hub.sr.ht gained a writable GraphQL API** for managing projects and project |
| 295 | resources. ~~Rechecked and shipped~~: project create/edit, resource | 295 | resources. ~~Rechecked and shipped~~: project create/edit, resource |
| 296 | link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes" | 296 | link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes" |
| 297 | below. `SCOPE.txt` still needs its "hub has no public API / no discovery" | 297 | below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since |
| 298 | claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a | 298 | been corrected. `mailingListSubscribe` was *not* unblocked — that needs a |
| 299 | per-list subscription field lists.sr.ht still lacks. | 299 | per-list subscription field lists.sr.ht still lacks. |
| 300 | - **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey` | 300 | - ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0). |
| 301 | are in the SDL). Hutch never calls them. | 301 | `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired |
| 302 | into the repository actions menu, owner-only, alongside ACLs. | ||
| 302 | 303 | ||
| 303 | Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps | 304 | Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps |
| 304 | were captured. | 305 | were captured. |
| 305 | 306 | ||
| 306 | Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one | 307 | Deploy keys — the one self-contained feature the ingest had already surfaced and |
| 307 | self-contained feature it has already surfaced and that the SDL confirms exists. | 308 | that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest |
| 308 | That gives the release something a user can see. Everything else the ingest turns | 309 | itself: research that files what changed rather than building. Everything else it |
| 309 | up gets filed, not built, and hub.sr.ht gets its own bucket below. | 310 | turns up gets filed, not built. |
| 310 | 311 | ||
| 311 | ### hub.sr.ht writes — projects and discovery done | 312 | ### hub.sr.ht writes — projects and discovery done |
| 312 | 313 | ||