Commit 55f362e4c3
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | |||
| 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | CODE_SIGN_STYLE = Automatic; | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 18; | 542 | CURRENT_PROJECT_VERSION = 19; |
| 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | ENABLE_PREVIEWS = YES; | 544 | ENABLE_PREVIEWS = YES; |
| 545 | GENERATE_INFOPLIST_FILE = YES; | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | |||
| 556 | "$(inherited)", | 556 | "$(inherited)", |
| 557 | "@executable_path/Frameworks", | 557 | "@executable_path/Frameworks", |
| 558 | ); | 558 | ); |
| 559 | MARKETING_VERSION = 2.7.1; | 559 | MARKETING_VERSION = 2.8.0; |
| 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | PRODUCT_NAME = "$(TARGET_NAME)"; | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | |||
| 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | CODE_SIGN_STYLE = Automatic; | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 18; | 579 | CURRENT_PROJECT_VERSION = 19; |
| 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | ENABLE_PREVIEWS = YES; | 581 | ENABLE_PREVIEWS = YES; |
| 582 | GENERATE_INFOPLIST_FILE = YES; | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | |||
| 593 | "$(inherited)", | 593 | "$(inherited)", |
| 594 | "@executable_path/Frameworks", | 594 | "@executable_path/Frameworks", |
| 595 | ); | 595 | ); |
| 596 | MARKETING_VERSION = 2.7.1; | 596 | MARKETING_VERSION = 2.8.0; |
| 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | PRODUCT_NAME = "$(TARGET_NAME)"; | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | |||
| 656 | APPLICATION_EXTENSION_API_ONLY = YES; | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | CODE_SIGN_STYLE = Automatic; | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 18; | 659 | CURRENT_PROJECT_VERSION = 19; |
| 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | GENERATE_INFOPLIST_FILE = NO; | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | |||
| 666 | "@executable_path/Frameworks", | 666 | "@executable_path/Frameworks", |
| 667 | "@executable_path/../../Frameworks", | 667 | "@executable_path/../../Frameworks", |
| 668 | ); | 668 | ); |
| 669 | MARKETING_VERSION = 2.7.1; | 669 | MARKETING_VERSION = 2.8.0; |
| 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | PRODUCT_NAME = "$(TARGET_NAME)"; | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | SKIP_INSTALL = YES; | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | |||
| 685 | APPLICATION_EXTENSION_API_ONLY = YES; | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | CODE_SIGN_STYLE = Automatic; | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 18; | 688 | CURRENT_PROJECT_VERSION = 19; |
| 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | GENERATE_INFOPLIST_FILE = NO; | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | |||
| 695 | "@executable_path/Frameworks", | 695 | "@executable_path/Frameworks", |
| 696 | "@executable_path/../../Frameworks", | 696 | "@executable_path/../../Frameworks", |
| 697 | ); | 697 | ); |
| 698 | MARKETING_VERSION = 2.7.1; | 698 | MARKETING_VERSION = 2.8.0; |
| 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | PRODUCT_NAME = "$(TARGET_NAME)"; | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | SKIP_INSTALL = YES; | 701 | SKIP_INSTALL = YES; |
Hutch/App/RootView.swift +3
| @@ -263,6 +263,7 @@ enum MoreDestination: Hashable { | |||
| 263 | } | 263 | } |
| 264 | 264 | ||
| 265 | enum MoreRoute: Hashable { | 265 | enum MoreRoute: Hashable { |
| 266 | case lookup | ||
| 266 | case lists | 267 | case lists |
| 267 | case pastes | 268 | case pastes |
| 268 | case settings | 269 | case settings |
| @@ -275,6 +276,8 @@ private struct MoreNavigationRoot: View { | |||
| 275 | MoreView() | 276 | MoreView() |
| 276 | .navigationDestination(for: MoreRoute.self) { route in | 277 | .navigationDestination(for: MoreRoute.self) { route in |
| 277 | switch route { | 278 | switch route { |
| 279 | case .lookup: | ||
| 280 | LookupView() | ||
| 278 | case .lists: | 281 | case .lists: |
| 279 | MailingListListView() | 282 | MailingListListView() |
| 280 | case .pastes: | 283 | case .pastes: |
Hutch/Networking/SRHTClient.swift +10
| @@ -98,6 +98,11 @@ final class SRHTClient: Sendable { | |||
| 98 | } | 98 | } |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | ||
| 102 | let errors = errorEnvelope.errors, !errors.isEmpty { | ||
| 103 | throw SRHTError.graphQLErrors(errors) | ||
| 104 | } | ||
| 105 | |||
| 101 | // Decode GraphQL response envelope | 106 | // Decode GraphQL response envelope |
| 102 | let graphQLResponse: GraphQLResponse<T> | 107 | let graphQLResponse: GraphQLResponse<T> |
| 103 | do { | 108 | do { |
| @@ -248,6 +253,11 @@ final class SRHTClient: Sendable { | |||
| 248 | } | 253 | } |
| 249 | } | 254 | } |
| 250 | 255 | ||
| 256 | if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | ||
| 257 | let errors = errorEnvelope.errors, !errors.isEmpty { | ||
| 258 | throw SRHTError.graphQLErrors(errors) | ||
| 259 | } | ||
| 260 | |||
| 251 | let graphQLResponse: GraphQLResponse<T> | 261 | let graphQLResponse: GraphQLResponse<T> |
| 252 | do { | 262 | do { |
| 253 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) | 263 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) |
Hutch/Views/Lookup/LookupView.swift added +387
| @@ -0,0 +1,387 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | enum LookupType: String, CaseIterable, Identifiable { | ||
| 4 | case user = "User" | ||
| 5 | case gitRepo = "Git Repo" | ||
| 6 | case hgRepo = "Hg Repo" | ||
| 7 | case mailingList = "Mailing List" | ||
| 8 | case tracker = "Tracker" | ||
| 9 | case buildJob = "Build Job" | ||
| 10 | |||
| 11 | var id: String { rawValue } | ||
| 12 | |||
| 13 | var placeholder: String { | ||
| 14 | switch self { | ||
| 15 | case .user: | ||
| 16 | "~username" | ||
| 17 | case .gitRepo, .hgRepo: | ||
| 18 | "~username/repo-name" | ||
| 19 | case .mailingList: | ||
| 20 | "~username/list-name" | ||
| 21 | case .tracker: | ||
| 22 | "~username/tracker-name" | ||
| 23 | case .buildJob: | ||
| 24 | "Job ID (e.g. 123456)" | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | var inputLabel: String { | ||
| 29 | switch self { | ||
| 30 | case .user: | ||
| 31 | "Username" | ||
| 32 | case .gitRepo, .hgRepo: | ||
| 33 | "Repository" | ||
| 34 | case .mailingList: | ||
| 35 | "Mailing List" | ||
| 36 | case .tracker: | ||
| 37 | "Tracker" | ||
| 38 | case .buildJob: | ||
| 39 | "Build Job ID" | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | enum LookupResult: Identifiable { | ||
| 45 | case user(User) | ||
| 46 | case repository(RepositorySummary) | ||
| 47 | case mailingList(InboxMailingListReference) | ||
| 48 | case tracker(TrackerSummary) | ||
| 49 | case buildJob(Int) | ||
| 50 | |||
| 51 | var id: String { | ||
| 52 | switch self { | ||
| 53 | case .user(let user): | ||
| 54 | "user:\(user.id)" | ||
| 55 | case .repository(let repository): | ||
| 56 | "repo:\(repository.id)" | ||
| 57 | case .mailingList(let mailingList): | ||
| 58 | "list:\(mailingList.id)" | ||
| 59 | case .tracker(let tracker): | ||
| 60 | "tracker:\(tracker.id)" | ||
| 61 | case .buildJob(let jobId): | ||
| 62 | "job:\(jobId)" | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | @Observable | ||
| 68 | @MainActor | ||
| 69 | final class LookupViewModel { | ||
| 70 | var selectedType: LookupType = .user | ||
| 71 | var inputText: String = "" | ||
| 72 | private(set) var result: LookupResult? | ||
| 73 | private(set) var isLooking = false | ||
| 74 | var error: String? | ||
| 75 | |||
| 76 | private let client: SRHTClient | ||
| 77 | private let appState: AppState | ||
| 78 | |||
| 79 | var resultBinding: Binding<LookupResult?> { | ||
| 80 | Binding( | ||
| 81 | get: { self.result }, | ||
| 82 | set: { newValue in | ||
| 83 | if newValue == nil { | ||
| 84 | self.result = nil | ||
| 85 | } | ||
| 86 | } | ||
| 87 | ) | ||
| 88 | } | ||
| 89 | |||
| 90 | init(client: SRHTClient, appState: AppState) { | ||
| 91 | self.client = client | ||
| 92 | self.appState = appState | ||
| 93 | } | ||
| 94 | |||
| 95 | func lookup() async { | ||
| 96 | result = nil | ||
| 97 | error = nil | ||
| 98 | isLooking = true | ||
| 99 | defer { isLooking = false } | ||
| 100 | |||
| 101 | do { | ||
| 102 | switch selectedType { | ||
| 103 | case .user: | ||
| 104 | result = try await lookupUser() | ||
| 105 | case .gitRepo: | ||
| 106 | result = try await lookupRepository(service: .git) | ||
| 107 | case .hgRepo: | ||
| 108 | result = try await lookupRepository(service: .hg) | ||
| 109 | case .mailingList: | ||
| 110 | result = try await lookupMailingList() | ||
| 111 | case .tracker: | ||
| 112 | result = try await lookupTracker() | ||
| 113 | case .buildJob: | ||
| 114 | result = try await lookupBuildJob() | ||
| 115 | } | ||
| 116 | } catch LookupError.invalidInput { | ||
| 117 | return | ||
| 118 | } catch { | ||
| 119 | self.error = error.userFacingMessage | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | private func parseOwnerAndName() -> (owner: String, name: String)? { | ||
| 124 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 125 | let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 126 | let parts = normalized.split(separator: "/", omittingEmptySubsequences: false) | ||
| 127 | |||
| 128 | guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { | ||
| 129 | error = "Enter a value in the format ~username/name." | ||
| 130 | return nil | ||
| 131 | } | ||
| 132 | |||
| 133 | return (String(parts[0]), String(parts[1])) | ||
| 134 | } | ||
| 135 | |||
| 136 | private func parseUsername() -> String? { | ||
| 137 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 138 | let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 139 | |||
| 140 | guard !normalized.isEmpty else { | ||
| 141 | error = "Enter a username." | ||
| 142 | return nil | ||
| 143 | } | ||
| 144 | |||
| 145 | return normalized | ||
| 146 | } | ||
| 147 | |||
| 148 | private func parseBuildJobId() -> Int? { | ||
| 149 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 150 | |||
| 151 | guard let jobId = Int(trimmed) else { | ||
| 152 | error = "Enter a numeric build job ID." | ||
| 153 | return nil | ||
| 154 | } | ||
| 155 | |||
| 156 | return jobId | ||
| 157 | } | ||
| 158 | |||
| 159 | private func lookupUser() async throws -> LookupResult { | ||
| 160 | guard let username = parseUsername() else { throw LookupError.invalidInput } | ||
| 161 | |||
| 162 | struct Response: Decodable, Sendable { | ||
| 163 | let user: User | ||
| 164 | } | ||
| 165 | |||
| 166 | let query = """ | ||
| 167 | query userLookup($username: String!) { | ||
| 168 | user: userByName(username: $username) { | ||
| 169 | id username canonicalName email avatar | ||
| 170 | } | ||
| 171 | } | ||
| 172 | """ | ||
| 173 | |||
| 174 | let result = try await client.execute( | ||
| 175 | service: .meta, | ||
| 176 | query: query, | ||
| 177 | variables: ["username": username], | ||
| 178 | responseType: Response.self | ||
| 179 | ) | ||
| 180 | |||
| 181 | return .user(result.user) | ||
| 182 | } | ||
| 183 | |||
| 184 | private func lookupRepository(service: SRHTService) async throws -> LookupResult { | ||
| 185 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | ||
| 186 | |||
| 187 | let repository = try await appState.resolveRepository(owner: owner, name: name, service: service) | ||
| 188 | let resolvedRepository = RepositorySummary( | ||
| 189 | id: repository.id, | ||
| 190 | rid: repository.rid, | ||
| 191 | service: service, | ||
| 192 | name: repository.name, | ||
| 193 | description: repository.description, | ||
| 194 | visibility: repository.visibility, | ||
| 195 | updated: repository.updated, | ||
| 196 | owner: repository.owner, | ||
| 197 | head: repository.head | ||
| 198 | ) | ||
| 199 | |||
| 200 | return .repository(resolvedRepository) | ||
| 201 | } | ||
| 202 | |||
| 203 | private func lookupMailingList() async throws -> LookupResult { | ||
| 204 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | ||
| 205 | |||
| 206 | struct Response: Decodable, Sendable { | ||
| 207 | let user: UserWithList | ||
| 208 | } | ||
| 209 | |||
| 210 | struct UserWithList: Decodable, Sendable { | ||
| 211 | let mailingList: InboxMailingListReference | ||
| 212 | } | ||
| 213 | |||
| 214 | let query = """ | ||
| 215 | query mailingListLookup($owner: String!, $name: String!) { | ||
| 216 | user(username: $owner) { | ||
| 217 | mailingList: list(name: $name) { | ||
| 218 | id rid name owner { canonicalName } | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | """ | ||
| 223 | |||
| 224 | let result = try await client.execute( | ||
| 225 | service: .lists, | ||
| 226 | query: query, | ||
| 227 | variables: ["owner": owner, "name": name], | ||
| 228 | responseType: Response.self | ||
| 229 | ) | ||
| 230 | |||
| 231 | return .mailingList(result.user.mailingList) | ||
| 232 | } | ||
| 233 | |||
| 234 | private func lookupTracker() async throws -> LookupResult { | ||
| 235 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | ||
| 236 | let tracker = try await appState.resolveTracker(owner: owner, name: name) | ||
| 237 | return .tracker(tracker) | ||
| 238 | } | ||
| 239 | |||
| 240 | private func lookupBuildJob() async throws -> LookupResult { | ||
| 241 | guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput } | ||
| 242 | |||
| 243 | struct Response: Decodable, Sendable { | ||
| 244 | let job: JobIdOnly | ||
| 245 | } | ||
| 246 | |||
| 247 | struct JobIdOnly: Decodable, Sendable { | ||
| 248 | let id: Int | ||
| 249 | } | ||
| 250 | |||
| 251 | let query = """ | ||
| 252 | query buildLookup($id: Int!) { | ||
| 253 | job(id: $id) { id } | ||
| 254 | } | ||
| 255 | """ | ||
| 256 | |||
| 257 | _ = try await client.execute( | ||
| 258 | service: .builds, | ||
| 259 | query: query, | ||
| 260 | variables: ["id": jobId], | ||
| 261 | responseType: Response.self | ||
| 262 | ) | ||
| 263 | |||
| 264 | return .buildJob(jobId) | ||
| 265 | } | ||
| 266 | |||
| 267 | private enum LookupError: Error { | ||
| 268 | case invalidInput | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | struct LookupView: View { | ||
| 273 | @Environment(AppState.self) private var appState | ||
| 274 | @State private var viewModel: LookupViewModel? | ||
| 275 | |||
| 276 | var body: some View { | ||
| 277 | Group { | ||
| 278 | if let viewModel { | ||
| 279 | content(viewModel) | ||
| 280 | } else { | ||
| 281 | SRHTLoadingStateView(message: "Preparing lookup…") | ||
| 282 | } | ||
| 283 | } | ||
| 284 | .navigationTitle("Look Up") | ||
| 285 | .task { | ||
| 286 | if viewModel == nil { | ||
| 287 | viewModel = LookupViewModel(client: appState.client, appState: appState) | ||
| 288 | } | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | @ViewBuilder | ||
| 293 | private func content(_ viewModel: LookupViewModel) -> some View { | ||
| 294 | @Bindable var vm = viewModel | ||
| 295 | |||
| 296 | Form { | ||
| 297 | Section { | ||
| 298 | Picker("Type", selection: $vm.selectedType) { | ||
| 299 | ForEach(LookupType.allCases) { type in | ||
| 300 | Text(type.rawValue).tag(type) | ||
| 301 | } | ||
| 302 | } | ||
| 303 | .pickerStyle(.menu) | ||
| 304 | |||
| 305 | TextField( | ||
| 306 | vm.selectedType.inputLabel, | ||
| 307 | text: $vm.inputText, | ||
| 308 | prompt: Text(vm.selectedType.placeholder) | ||
| 309 | ) | ||
| 310 | .textInputAutocapitalization(.never) | ||
| 311 | .autocorrectionDisabled() | ||
| 312 | .submitLabel(.search) | ||
| 313 | .onSubmit { | ||
| 314 | Task { await vm.lookup() } | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | Section { | ||
| 319 | HStack { | ||
| 320 | Button("Look Up") { | ||
| 321 | Task { await vm.lookup() } | ||
| 322 | } | ||
| 323 | .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking) | ||
| 324 | |||
| 325 | if vm.isLooking { | ||
| 326 | Spacer() | ||
| 327 | ProgressView() | ||
| 328 | } | ||
| 329 | } | ||
| 330 | } | ||
| 331 | } | ||
| 332 | .formStyle(.grouped) | ||
| 333 | .srhtErrorBanner(error: $vm.error) | ||
| 334 | .sheet(item: vm.resultBinding) { result in | ||
| 335 | NavigationStack { | ||
| 336 | lookupDestination(result) | ||
| 337 | } | ||
| 338 | .navigationDestination(for: MoreRoute.self) { route in | ||
| 339 | switch route { | ||
| 340 | case .lookup: | ||
| 341 | LookupView() | ||
| 342 | case .lists: | ||
| 343 | MailingListListView() | ||
| 344 | case .pastes: | ||
| 345 | PasteListView() | ||
| 346 | case .settings: | ||
| 347 | SettingsView() | ||
| 348 | case .mailingList(let mailingList): | ||
| 349 | MailingListDetailView(mailingList: mailingList) | ||
| 350 | case .thread(let thread): | ||
| 351 | ThreadDetailView( | ||
| 352 | thread: thread, | ||
| 353 | onViewed: { | ||
| 354 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 355 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 356 | }, | ||
| 357 | onMarkRead: { | ||
| 358 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 359 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 360 | }, | ||
| 361 | onMarkUnread: { | ||
| 362 | InboxReadStateStore.markUnread(for: thread.id) | ||
| 363 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | ||
| 364 | } | ||
| 365 | ) | ||
| 366 | } | ||
| 367 | } | ||
| 368 | .environment(appState) | ||
| 369 | } | ||
| 370 | } | ||
| 371 | |||
| 372 | @ViewBuilder | ||
| 373 | private func lookupDestination(_ result: LookupResult) -> some View { | ||
| 374 | switch result { | ||
| 375 | case .user(let user): | ||
| 376 | UserProfileView(user: user) | ||
| 377 | case .repository(let repository): | ||
| 378 | RepositoryDetailView(repository: repository) | ||
| 379 | case .mailingList(let mailingList): | ||
| 380 | MailingListDetailView(mailingList: mailingList) | ||
| 381 | case .tracker(let tracker): | ||
| 382 | TicketListView(tracker: tracker) | ||
| 383 | case .buildJob(let jobId): | ||
| 384 | BuildDetailView(jobId: jobId) | ||
| 385 | } | ||
| 386 | } | ||
| 387 | } | ||
Hutch/Views/Lookup/UserProfileView.swift added +52
| @@ -0,0 +1,52 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct UserProfileView: View { | ||
| 4 | let user: User | ||
| 5 | |||
| 6 | @Environment(AppState.self) private var appState | ||
| 7 | |||
| 8 | var body: some View { | ||
| 9 | List { | ||
| 10 | if let avatarURL = user.avatar.flatMap(URL.init(string:)) { | ||
| 11 | Section { | ||
| 12 | HStack { | ||
| 13 | Spacer() | ||
| 14 | AsyncImage(url: avatarURL) { phase in | ||
| 15 | switch phase { | ||
| 16 | case .success(let image): | ||
| 17 | image | ||
| 18 | .resizable() | ||
| 19 | .scaledToFill() | ||
| 20 | case .failure, .empty: | ||
| 21 | Image(systemName: "person.crop.circle.fill") | ||
| 22 | .resizable() | ||
| 23 | .scaledToFit() | ||
| 24 | .foregroundStyle(.secondary) | ||
| 25 | .padding(20) | ||
| 26 | @unknown default: | ||
| 27 | EmptyView() | ||
| 28 | } | ||
| 29 | } | ||
| 30 | .frame(width: 96, height: 96) | ||
| 31 | .clipShape(Circle()) | ||
| 32 | .overlay { | ||
| 33 | Circle() | ||
| 34 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) | ||
| 35 | } | ||
| 36 | Spacer() | ||
| 37 | } | ||
| 38 | .listRowBackground(Color.clear) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | Section { | ||
| 43 | LabeledContent("Username", value: user.username) | ||
| 44 | LabeledContent("Canonical Name", value: user.canonicalName) | ||
| 45 | LabeledContent("Email", value: user.email) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | .listStyle(.insetGrouped) | ||
| 49 | .navigationTitle(user.canonicalName) | ||
| 50 | .navigationBarTitleDisplayMode(.inline) | ||
| 51 | } | ||
| 52 | } | ||
Hutch/Views/More/MoreView.swift +4
| @@ -14,6 +14,10 @@ struct MoreView: View { | |||
| 14 | var body: some View { | 14 | var body: some View { |
| 15 | List { | 15 | List { |
| 16 | Section { | 16 | Section { |
| 17 | NavigationLink(value: MoreRoute.lookup) { | ||
| 18 | Label("Look Up", systemImage: "magnifyingglass") | ||
| 19 | } | ||
| 20 | |||
| 17 | NavigationLink(value: MoreRoute.lists) { | 21 | NavigationLink(value: MoreRoute.lists) { |
| 18 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") | 22 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") |
| 19 | } | 23 | } |
Hutch/Views/Projects/ProjectMailingListView.swift +17 −1
| @@ -257,7 +257,23 @@ struct MailingListDetailView: View { | |||
| 257 | 257 | ||
| 258 | List { | 258 | List { |
| 259 | ForEach(viewModel.filteredThreads) { thread in | 259 | ForEach(viewModel.filteredThreads) { thread in |
| 260 | NavigationLink(value: MoreRoute.thread(thread)) { | 260 | NavigationLink { |
| 261 | ThreadDetailView( | ||
| 262 | thread: thread, | ||
| 263 | onViewed: { | ||
| 264 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 265 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 266 | }, | ||
| 267 | onMarkRead: { | ||
| 268 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | ||
| 269 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 270 | }, | ||
| 271 | onMarkUnread: { | ||
| 272 | InboxReadStateStore.markUnread(for: thread.id) | ||
| 273 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | ||
| 274 | } | ||
| 275 | ) | ||
| 276 | } label: { | ||
| 261 | InboxThreadRow(thread: thread) | 277 | InboxThreadRow(thread: thread) |
| 262 | } | 278 | } |
| 263 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | 279 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { |
Hutch/Views/Repositories/HgRepositoryDetailView.swift +16 −4
| @@ -14,6 +14,11 @@ struct HgRepositoryDetailView: View { | |||
| 14 | @State private var isShowingRepositoryDetails = false | 14 | @State private var isShowingRepositoryDetails = false |
| 15 | @State private var showBrowseRefPicker = false | 15 | @State private var showBrowseRefPicker = false |
| 16 | 16 | ||
| 17 | private var canManageRepository: Bool { | ||
| 18 | guard let currentUser = appState.currentUser else { return false } | ||
| 19 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | ||
| 20 | } | ||
| 21 | |||
| 17 | private var shareURL: URL? { | 22 | private var shareURL: URL? { |
| 18 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } | 23 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } |
| 19 | return SRHTWebURL.file( | 24 | return SRHTWebURL.file( |
| @@ -51,10 +56,12 @@ struct HgRepositoryDetailView: View { | |||
| 51 | Image(systemName: "square.and.arrow.up") | 56 | Image(systemName: "square.and.arrow.up") |
| 52 | } | 57 | } |
| 53 | 58 | ||
| 54 | Button { | 59 | if canManageRepository { |
| 55 | showSettings = true | 60 | Button { |
| 56 | } label: { | 61 | showSettings = true |
| 57 | Image(systemName: "gear") | 62 | } label: { |
| 63 | Image(systemName: "gear") | ||
| 64 | } | ||
| 58 | } | 65 | } |
| 59 | } | 66 | } |
| 60 | } | 67 | } |
| @@ -85,6 +92,11 @@ struct HgRepositoryDetailView: View { | |||
| 85 | } | 92 | } |
| 86 | } | 93 | } |
| 87 | 94 | ||
| 95 | private func normalizedUsername(_ value: String) -> String { | ||
| 96 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 97 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 98 | } | ||
| 99 | |||
| 88 | @ViewBuilder | 100 | @ViewBuilder |
| 89 | private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { | 101 | private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 90 | VStack(spacing: 0) { | 102 | VStack(spacing: 0) { |
Hutch/Views/Repositories/RepositoryDetailView.swift +16 −4
| @@ -11,6 +11,11 @@ struct RepositoryDetailView: View { | |||
| 11 | @State private var showSettings = false | 11 | @State private var showSettings = false |
| 12 | @State private var displayName: String | 12 | @State private var displayName: String |
| 13 | 13 | ||
| 14 | private var canManageRepository: Bool { | ||
| 15 | guard let currentUser = appState.currentUser else { return false } | ||
| 16 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | ||
| 17 | } | ||
| 18 | |||
| 14 | init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { | 19 | init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { |
| 15 | self.repository = repository | 20 | self.repository = repository |
| 16 | self.onDeleted = onDeleted | 21 | self.onDeleted = onDeleted |
| @@ -36,10 +41,12 @@ struct RepositoryDetailView: View { | |||
| 36 | Image(systemName: "square.and.arrow.up") | 41 | Image(systemName: "square.and.arrow.up") |
| 37 | } | 42 | } |
| 38 | 43 | ||
| 39 | Button { | 44 | if canManageRepository { |
| 40 | showSettings = true | 45 | Button { |
| 41 | } label: { | 46 | showSettings = true |
| 42 | Image(systemName: "gear") | 47 | } label: { |
| 48 | Image(systemName: "gear") | ||
| 49 | } | ||
| 43 | } | 50 | } |
| 44 | } | 51 | } |
| 45 | } | 52 | } |
| @@ -104,4 +111,9 @@ struct RepositoryDetailView: View { | |||
| 104 | set: { viewModel.error = $0 } | 111 | set: { viewModel.error = $0 } |
| 105 | )) | 112 | )) |
| 106 | } | 113 | } |
| 114 | |||
| 115 | private func normalizedUsername(_ value: String) -> String { | ||
| 116 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 117 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 118 | } | ||
| 107 | } | 119 | } |
Hutch/Views/Tickets/TicketDetailView.swift +14 −2
| @@ -20,6 +20,11 @@ struct TicketDetailView: View { | |||
| 20 | // Comment composer mode | 20 | // Comment composer mode |
| 21 | @State private var commentMode: CommentMode = .write | 21 | @State private var commentMode: CommentMode = .write |
| 22 | 22 | ||
| 23 | private var isOwnedByCurrentUser: Bool { | ||
| 24 | guard let currentUser = appState.currentUser else { return false } | ||
| 25 | return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername) | ||
| 26 | } | ||
| 27 | |||
| 23 | private enum CommentMode: String, CaseIterable { | 28 | private enum CommentMode: String, CaseIterable { |
| 24 | case write = "Write" | 29 | case write = "Write" |
| 25 | case preview = "Preview" | 30 | case preview = "Preview" |
| @@ -41,7 +46,7 @@ struct TicketDetailView: View { | |||
| 41 | Image(systemName: "square.and.arrow.up") | 46 | Image(systemName: "square.and.arrow.up") |
| 42 | } | 47 | } |
| 43 | 48 | ||
| 44 | if let viewModel, viewModel.ticket != nil { | 49 | if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser { |
| 45 | actionsMenu(viewModel) | 50 | actionsMenu(viewModel) |
| 46 | } | 51 | } |
| 47 | } | 52 | } |
| @@ -195,7 +200,9 @@ struct TicketDetailView: View { | |||
| 195 | 200 | ||
| 196 | Spacer(minLength: 12) | 201 | Spacer(minLength: 12) |
| 197 | 202 | ||
| 198 | assignToMeButton(ticket: ticket, viewModel: viewModel) | 203 | if isOwnedByCurrentUser { |
| 204 | assignToMeButton(ticket: ticket, viewModel: viewModel) | ||
| 205 | } | ||
| 199 | } | 206 | } |
| 200 | 207 | ||
| 201 | HStack(spacing: 8) { | 208 | HStack(spacing: 8) { |
| @@ -334,6 +341,11 @@ struct TicketDetailView: View { | |||
| 334 | } | 341 | } |
| 335 | .padding() | 342 | .padding() |
| 336 | } | 343 | } |
| 344 | |||
| 345 | private func normalizedUsername(_ value: String) -> String { | ||
| 346 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 347 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 348 | } | ||
| 337 | } | 349 | } |
| 338 | 350 | ||
| 339 | // MARK: - Self-Sizing Markdown Web View | 351 | // MARK: - Self-Sizing Markdown Web View |
Hutch/Views/Tickets/TicketListView.swift +8 −1
| @@ -18,6 +18,11 @@ struct TicketListView: View { | |||
| 18 | @State private var showTrackerLabels = false | 18 | @State private var showTrackerLabels = false |
| 19 | @State private var showDeleteTrackerConfirmation = false | 19 | @State private var showDeleteTrackerConfirmation = false |
| 20 | 20 | ||
| 21 | private var isOwnedByCurrentUser: Bool { | ||
| 22 | guard let currentUser = appState.currentUser else { return false } | ||
| 23 | return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) | ||
| 24 | } | ||
| 25 | |||
| 21 | init( | 26 | init( |
| 22 | tracker: TrackerSummary, | 27 | tracker: TrackerSummary, |
| 23 | onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, | 28 | onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, |
| @@ -58,7 +63,9 @@ struct TicketListView: View { | |||
| 58 | } | 63 | } |
| 59 | .accessibilityLabel("Create ticket") | 64 | .accessibilityLabel("Create ticket") |
| 60 | 65 | ||
| 61 | trackerActionsMenu | 66 | if isOwnedByCurrentUser { |
| 67 | trackerActionsMenu | ||
| 68 | } | ||
| 62 | } | 69 | } |
| 63 | } | 70 | } |
| 64 | } | 71 | } |