Commit 3078d39ae2
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 72; | 518 | CURRENT_PROJECT_VERSION = 73; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 520 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | "$(inherited)", | 532 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 533 | "@executable_path/Frameworks", |
| 534 | ); | 534 | ); |
| 535 | MARKETING_VERSION = 3.1.4; | 535 | MARKETING_VERSION = 3.1.5; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 72; | 555 | CURRENT_PROJECT_VERSION = 73; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 557 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | |||
| 569 | "$(inherited)", | 569 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 570 | "@executable_path/Frameworks", |
| 571 | ); | 571 | ); |
| 572 | MARKETING_VERSION = 3.1.4; | 572 | MARKETING_VERSION = 3.1.5; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 72; | 635 | CURRENT_PROJECT_VERSION = 73; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 642 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 643 | "@executable_path/../../Frameworks", |
| 644 | ); | 644 | ); |
| 645 | MARKETING_VERSION = 3.1.4; | 645 | MARKETING_VERSION = 3.1.5; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 72; | 664 | CURRENT_PROJECT_VERSION = 73; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 671 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 672 | "@executable_path/../../Frameworks", |
| 673 | ); | 673 | ); |
| 674 | MARKETING_VERSION = 3.1.4; | 674 | MARKETING_VERSION = 3.1.5; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 677 | SKIP_INSTALL = YES; |
Hutch/Networking/SRHTClient.swift +13 −12
| @@ -23,13 +23,13 @@ final class SRHTClient: Sendable { | |||
| 23 | 23 | ||
| 24 | /// The personal access token used for `Authorization: Bearer` headers. | 24 | /// The personal access token used for `Authorization: Bearer` headers. |
| 25 | /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. | 25 | /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. |
| 26 | private let _token: OSAllocatedUnfairLock<String?> | 26 | private let tokenLock: OSAllocatedUnfairLock<String?> |
| 27 | 27 | ||
| 28 | /// In-memory response cache for stale-while-revalidate pattern. | 28 | /// In-memory response cache for stale-while-revalidate pattern. |
| 29 | let responseCache = ResponseCache() | 29 | let responseCache = ResponseCache() |
| 30 | 30 | ||
| 31 | var hasToken: Bool { | 31 | var hasToken: Bool { |
| 32 | _token.withLock { $0 != nil } | 32 | tokenLock.withLock { $0 != nil } |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | init(session: URLSession = .shared, token: String? = nil) { | 35 | init(session: URLSession = .shared, token: String? = nil) { |
| @@ -37,12 +37,12 @@ final class SRHTClient: Sendable { | |||
| 37 | self.decoder = JSONDecoder() | 37 | self.decoder = JSONDecoder() |
| 38 | self.decoder.dateDecodingStrategy = .srhtFlexible | 38 | self.decoder.dateDecodingStrategy = .srhtFlexible |
| 39 | self.encoder = JSONEncoder() | 39 | self.encoder = JSONEncoder() |
| 40 | self._token = OSAllocatedUnfairLock(initialState: token) | 40 | self.tokenLock = OSAllocatedUnfairLock(initialState: token) |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | /// Update the stored token (e.g. after the user saves a new one in Keychain). | 43 | /// Update the stored token (e.g. after the user saves a new one in Keychain). |
| 44 | func setToken(_ token: String?) { | 44 | func setToken(_ token: String?) { |
| 45 | _token.withLock { $0 = token } | 45 | tokenLock.withLock { $0 = token } |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | /// Execute a GraphQL query or mutation against a SourceHut service. | 48 | /// Execute a GraphQL query or mutation against a SourceHut service. |
| @@ -59,7 +59,7 @@ final class SRHTClient: Sendable { | |||
| 59 | variables: [String: any Sendable]? = nil, | 59 | variables: [String: any Sendable]? = nil, |
| 60 | responseType _: T.Type | 60 | responseType _: T.Type |
| 61 | ) async throws -> T { | 61 | ) async throws -> T { |
| 62 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | 62 | guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { |
| 63 | throw SRHTError.unauthorized | 63 | throw SRHTError.unauthorized |
| 64 | } | 64 | } |
| 65 | 65 | ||
| @@ -173,7 +173,7 @@ final class SRHTClient: Sendable { | |||
| 173 | file: MultipartUploadFile, | 173 | file: MultipartUploadFile, |
| 174 | responseType _: T.Type | 174 | responseType _: T.Type |
| 175 | ) async throws -> T { | 175 | ) async throws -> T { |
| 176 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | 176 | guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { |
| 177 | throw SRHTError.unauthorized | 177 | throw SRHTError.unauthorized |
| 178 | } | 178 | } |
| 179 | 179 | ||
| @@ -307,7 +307,7 @@ final class SRHTClient: Sendable { | |||
| 307 | files: [MultipartUploadFile], | 307 | files: [MultipartUploadFile], |
| 308 | responseType _: T.Type | 308 | responseType _: T.Type |
| 309 | ) async throws -> T { | 309 | ) async throws -> T { |
| 310 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | 310 | guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { |
| 311 | throw SRHTError.unauthorized | 311 | throw SRHTError.unauthorized |
| 312 | } | 312 | } |
| 313 | 313 | ||
| @@ -417,7 +417,7 @@ final class SRHTClient: Sendable { | |||
| 417 | service: service, | 417 | service: service, |
| 418 | query: query, | 418 | query: query, |
| 419 | variables: variables, | 419 | variables: variables, |
| 420 | responseType: responseType, | 420 | responseType: T.self, |
| 421 | cacheKey: cacheKey | 421 | cacheKey: cacheKey |
| 422 | ) | 422 | ) |
| 423 | } | 423 | } |
| @@ -430,7 +430,7 @@ final class SRHTClient: Sendable { | |||
| 430 | responseType _: T.Type, | 430 | responseType _: T.Type, |
| 431 | cacheKey: String | 431 | cacheKey: String |
| 432 | ) async throws -> T { | 432 | ) async throws -> T { |
| 433 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | 433 | guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { |
| 434 | throw SRHTError.unauthorized | 434 | throw SRHTError.unauthorized |
| 435 | } | 435 | } |
| 436 | 436 | ||
| @@ -527,7 +527,7 @@ final class SRHTClient: Sendable { | |||
| 527 | /// Fetch the contents of a URL as plain text, using the same authorization header. | 527 | /// Fetch the contents of a URL as plain text, using the same authorization header. |
| 528 | /// Used for build logs and other non-GraphQL resources. | 528 | /// Used for build logs and other non-GraphQL resources. |
| 529 | func fetchText(url: URL) async throws -> String { | 529 | func fetchText(url: URL) async throws -> String { |
| 530 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | 530 | guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { |
| 531 | throw SRHTError.unauthorized | 531 | throw SRHTError.unauthorized |
| 532 | } | 532 | } |
| 533 | guard Self.isTrustedAuthenticatedTextURL(url) else { | 533 | guard Self.isTrustedAuthenticatedTextURL(url) else { |
| @@ -593,12 +593,13 @@ final class SRHTClient: Sendable { | |||
| 593 | resultKeyPath: String | 593 | resultKeyPath: String |
| 594 | ) async throws -> [T] { | 594 | ) async throws -> [T] { |
| 595 | var all: [T] = [] | 595 | var all: [T] = [] |
| 596 | for try await element in paginated( | 596 | let pages: SRHTPaginatedSequence<T> = paginated( |
| 597 | service: service, | 597 | service: service, |
| 598 | query: query, | 598 | query: query, |
| 599 | variables: variables, | 599 | variables: variables, |
| 600 | resultKeyPath: resultKeyPath | 600 | resultKeyPath: resultKeyPath |
| 601 | ) { | 601 | ) |
| 602 | for try await element in pages { | ||
| 602 | all.append(element) | 603 | all.append(element) |
| 603 | } | 604 | } |
| 604 | return all | 605 | return all |
Hutch/Views/Builds/BuildListViewModel.swift +3 −2
| @@ -182,12 +182,13 @@ final class BuildListViewModel { | |||
| 182 | /// Fetch the first page of jobs. Shows cached data instantly if available, | 182 | /// Fetch the first page of jobs. Shows cached data instantly if available, |
| 183 | /// then refreshes from the network in the background. | 183 | /// then refreshes from the network in the background. |
| 184 | func loadJobs() async { | 184 | func loadJobs() async { |
| 185 | // Show cached data immediately on first load | 185 | // Show cached data immediately on first load (may populate `jobs` from cache). |
| 186 | if jobs.isEmpty { | 186 | if jobs.isEmpty { |
| 187 | loadFromCache() | 187 | loadFromCache() |
| 188 | } | 188 | } |
| 189 | 189 | ||
| 190 | if jobs.isEmpty { | 190 | let treatAsInitialLoad = jobs.isEmpty |
| 191 | if treatAsInitialLoad { | ||
| 191 | isLoading = true | 192 | isLoading = true |
| 192 | } else { | 193 | } else { |
| 193 | isRefreshing = true | 194 | isRefreshing = true |
Hutch/Views/Pastes/PasteListView.swift +4 −4
| @@ -276,14 +276,14 @@ private struct CreatePasteSheet: View { | |||
| 276 | NavigationStack { | 276 | NavigationStack { |
| 277 | Form { | 277 | Form { |
| 278 | Section("Files") { | 278 | Section("Files") { |
| 279 | ForEach($files) { $file in | 279 | ForEach($files) { fileBinding in |
| 280 | VStack(alignment: .leading, spacing: 8) { | 280 | VStack(alignment: .leading, spacing: 8) { |
| 281 | TextField("Filename (optional)", text: $file.filename) | 281 | TextField("Filename (optional)", text: fileBinding.filename) |
| 282 | .autocorrectionDisabled() | 282 | .autocorrectionDisabled() |
| 283 | .textInputAutocapitalization(.never) | 283 | .textInputAutocapitalization(.never) |
| 284 | 284 | ||
| 285 | ZStack(alignment: .topLeading) { | 285 | ZStack(alignment: .topLeading) { |
| 286 | if file.contents.isEmpty { | 286 | if fileBinding.wrappedValue.contents.isEmpty { |
| 287 | Text("Paste contents") | 287 | Text("Paste contents") |
| 288 | .foregroundStyle(.tertiary) | 288 | .foregroundStyle(.tertiary) |
| 289 | .padding(.top, 8) | 289 | .padding(.top, 8) |
| @@ -291,7 +291,7 @@ private struct CreatePasteSheet: View { | |||
| 291 | .allowsHitTesting(false) | 291 | .allowsHitTesting(false) |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 294 | TextEditor(text: $file.contents) | 294 | TextEditor(text: fileBinding.contents) |
| 295 | .font(.system(.body, design: .monospaced)) | 295 | .font(.system(.body, design: .monospaced)) |
| 296 | .frame(minHeight: 180) | 296 | .frame(minHeight: 180) |
| 297 | } | 297 | } |
Hutch/Views/Repositories/CommitDetailViewModel.swift +6 −3
| @@ -7,8 +7,11 @@ private struct CommitResponse: Decodable, Sendable { | |||
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | private struct CommitRepository: Decodable, Sendable { | 9 | private struct CommitRepository: Decodable, Sendable { |
| 10 | // swiftlint:disable:next identifier_name | 10 | let revparseSingle: CommitDetail |
| 11 | let revparse_single: CommitDetail | 11 | |
| 12 | enum CodingKeys: String, CodingKey { | ||
| 13 | case revparseSingle = "revparse_single" | ||
| 14 | } | ||
| 12 | } | 15 | } |
| 13 | 16 | ||
| 14 | // MARK: - View Model | 17 | // MARK: - View Model |
| @@ -66,7 +69,7 @@ final class CommitDetailViewModel { | |||
| 66 | 69 | ||
| 67 | do { | 70 | do { |
| 68 | let result = try await executeWithRetry() | 71 | let result = try await executeWithRetry() |
| 69 | commit = result.repository?.revparse_single | 72 | commit = result.repository?.revparseSingle |
| 70 | } catch { | 73 | } catch { |
| 71 | self.error = error.userFacingMessage | 74 | self.error = error.userFacingMessage |
| 72 | } | 75 | } |
Hutch/Views/Repositories/FileTreeViewModel.swift +6 −2
| @@ -7,7 +7,11 @@ private struct RevparseResponse: Decodable, Sendable { | |||
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | private struct RevparseRepository: Decodable, Sendable { | 9 | private struct RevparseRepository: Decodable, Sendable { |
| 10 | let revparse_single: RevparseCommit? | 10 | let revparseSingle: RevparseCommit? |
| 11 | |||
| 12 | enum CodingKeys: String, CodingKey { | ||
| 13 | case revparseSingle = "revparse_single" | ||
| 14 | } | ||
| 11 | } | 15 | } |
| 12 | 16 | ||
| 13 | private struct RevparseCommit: Decodable, Sendable { | 17 | private struct RevparseCommit: Decodable, Sendable { |
| @@ -277,7 +281,7 @@ final class FileTreeViewModel { | |||
| 277 | } | 281 | } |
| 278 | throw error | 282 | throw error |
| 279 | } | 283 | } |
| 280 | if let tree = result.repository?.revparse_single?.tree, | 284 | if let tree = result.repository?.revparseSingle?.tree, |
| 281 | let rootId = tree.id { | 285 | let rootId = tree.id { |
| 282 | navStack = [FileNavEntry(name: "root", treeId: rootId)] | 286 | navStack = [FileNavEntry(name: "root", treeId: rootId)] |
| 283 | var allEntries = tree.entries?.results ?? [] | 287 | var allEntries = tree.entries?.results ?? [] |