Commit 0aff03c96c
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 = 65; | 518 | CURRENT_PROJECT_VERSION = 66; |
| 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.0.4; | 535 | MARKETING_VERSION = 3.0.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 = 65; | 555 | CURRENT_PROJECT_VERSION = 66; |
| 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.0.4; | 572 | MARKETING_VERSION = 3.0.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 = 65; | 635 | CURRENT_PROJECT_VERSION = 66; |
| 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.0.4; | 645 | MARKETING_VERSION = 3.0.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 = 65; | 664 | CURRENT_PROJECT_VERSION = 66; |
| 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.0.4; | 674 | MARKETING_VERSION = 3.0.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/Views/Repositories/RepositoryDetailViewModel.swift +19 −5
| @@ -226,7 +226,7 @@ final class RepositoryDetailViewModel { | |||
| 226 | responseType: LogResponse.self | 226 | responseType: LogResponse.self |
| 227 | ) | 227 | ) |
| 228 | } catch { | 228 | } catch { |
| 229 | if isMissingGitReferenceError(error) { | 229 | if isEmptyRepositoryError(error) { |
| 230 | return LogPage(results: [], cursor: nil) | 230 | return LogPage(results: [], cursor: nil) |
| 231 | } | 231 | } |
| 232 | throw error | 232 | throw error |
| @@ -273,7 +273,12 @@ final class RepositoryDetailViewModel { | |||
| 273 | branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() } | 273 | branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() } |
| 274 | tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() } | 274 | tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() } |
| 275 | } catch { | 275 | } catch { |
| 276 | self.error = error.userFacingMessage | 276 | if isEmptyRepositoryError(error) { |
| 277 | branches = [] | ||
| 278 | tags = [] | ||
| 279 | } else { | ||
| 280 | self.error = error.userFacingMessage | ||
| 281 | } | ||
| 277 | } | 282 | } |
| 278 | 283 | ||
| 279 | isLoadingRefs = false | 284 | isLoadingRefs = false |
| @@ -340,7 +345,7 @@ final class RepositoryDetailViewModel { | |||
| 340 | responseType: PathResponse.self | 345 | responseType: PathResponse.self |
| 341 | ) | 346 | ) |
| 342 | } catch { | 347 | } catch { |
| 343 | if isMissingGitReferenceError(error) { | 348 | if isEmptyRepositoryError(error) { |
| 344 | readmeContent = nil | 349 | readmeContent = nil |
| 345 | readmePath = nil | 350 | readmePath = nil |
| 346 | readmeLoaded = true | 351 | readmeLoaded = true |
| @@ -371,8 +376,13 @@ final class RepositoryDetailViewModel { | |||
| 371 | } | 376 | } |
| 372 | } | 377 | } |
| 373 | 378 | ||
| 374 | private func isMissingGitReferenceError(_ error: Error) -> Bool { | 379 | private func isEmptyRepositoryError(_ error: Error) -> Bool { |
| 375 | error.matchesGraphQLErrorClassification(.missingReference) | 380 | error.matchesGraphQLErrorClassification(.missingReference) |
| 381 | || error.matchesGraphQLErrorClassification(.unknownRevision) | ||
| 382 | || error.matchesGraphQLErrorClassification(.noRows) | ||
| 383 | || error.matchesGraphQLErrorClassification(.notFound) | ||
| 384 | || error.containsGraphQLErrorMessage("missing") | ||
| 385 | || error.containsGraphQLErrorMessage("internal system error") | ||
| 376 | } | 386 | } |
| 377 | 387 | ||
| 378 | // MARK: - Artifacts | 388 | // MARK: - Artifacts |
| @@ -417,7 +427,11 @@ final class RepositoryDetailViewModel { | |||
| 417 | .filter { !$0.artifacts.results.isEmpty } | 427 | .filter { !$0.artifacts.results.isEmpty } |
| 418 | .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) } | 428 | .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) } |
| 419 | } catch { | 429 | } catch { |
| 420 | self.error = error.userFacingMessage | 430 | if isEmptyRepositoryError(error) { |
| 431 | referenceArtifacts = [] | ||
| 432 | } else { | ||
| 433 | self.error = error.userFacingMessage | ||
| 434 | } | ||
| 421 | } | 435 | } |
| 422 | 436 | ||
| 423 | isLoadingArtifacts = false | 437 | isLoadingArtifacts = false |
Hutch/Views/Tickets/TicketListViewModel.swift +27 −7
| @@ -99,13 +99,13 @@ final class TicketListViewModel { | |||
| 99 | var filter: TicketFilter = .open { | 99 | var filter: TicketFilter = .open { |
| 100 | didSet { | 100 | didSet { |
| 101 | persistFilterState() | 101 | persistFilterState() |
| 102 | updateFilteredTickets() | 102 | resetPaginationAndUpdateFilters() |
| 103 | } | 103 | } |
| 104 | } | 104 | } |
| 105 | var selectedLabelIDs: Set<Int> = [] { | 105 | var selectedLabelIDs: Set<Int> = [] { |
| 106 | didSet { | 106 | didSet { |
| 107 | persistFilterState() | 107 | persistFilterState() |
| 108 | updateFilteredTickets() | 108 | resetPaginationAndUpdateFilters() |
| 109 | } | 109 | } |
| 110 | } | 110 | } |
| 111 | var searchText = "" { | 111 | var searchText = "" { |
| @@ -283,6 +283,13 @@ final class TicketListViewModel { | |||
| 283 | } | 283 | } |
| 284 | } | 284 | } |
| 285 | 285 | ||
| 286 | private func resetPaginationAndUpdateFilters() { | ||
| 287 | // Reset pagination when filters change since the cursor is tied to the unfiltered dataset | ||
| 288 | cursor = nil | ||
| 289 | hasMore = true | ||
| 290 | updateFilteredTickets() | ||
| 291 | } | ||
| 292 | |||
| 286 | // MARK: - Public API | 293 | // MARK: - Public API |
| 287 | 294 | ||
| 288 | func loadTickets() async { | 295 | func loadTickets() async { |
| @@ -305,19 +312,32 @@ final class TicketListViewModel { | |||
| 305 | } | 312 | } |
| 306 | 313 | ||
| 307 | func loadMoreIfNeeded(currentItem: TicketSummary) async { | 314 | func loadMoreIfNeeded(currentItem: TicketSummary) async { |
| 308 | guard let last = tickets.last, | 315 | // Check if currentItem is in the filtered list and close to the end |
| 309 | last.id == currentItem.id, | 316 | guard hasMore, !isLoadingMore else { return } |
| 310 | hasMore, | 317 | |
| 311 | !isLoadingMore else { | 318 | guard let index = filteredTickets.firstIndex(where: { $0.id == currentItem.id }) else { |
| 312 | return | 319 | return |
| 313 | } | 320 | } |
| 314 | 321 | ||
| 322 | let itemsFromEnd = filteredTickets.count - index - 1 | ||
| 323 | guard itemsFromEnd < 5 else { return } | ||
| 324 | |||
| 315 | isLoadingMore = true | 325 | isLoadingMore = true |
| 316 | 326 | ||
| 317 | do { | 327 | do { |
| 318 | let page = try await fetchPage(cursor: cursor) | 328 | let page = try await fetchPage(cursor: cursor) |
| 319 | tickets.append(contentsOf: page.results) | 329 | |
| 330 | // Deduplicate: only add tickets that don't already exist | ||
| 331 | let existingIDs = Set(tickets.map(\.id)) | ||
| 332 | let newTickets = page.results.filter { !existingIDs.contains($0.id) } | ||
| 333 | |||
| 334 | // If we got back the same tickets, the API cursor pagination isn't working | ||
| 335 | if newTickets.isEmpty && !page.results.isEmpty { | ||
| 336 | hasMore = false | ||
| 337 | } | ||
| 338 | |||
| 320 | cursor = page.cursor | 339 | cursor = page.cursor |
| 340 | tickets.append(contentsOf: newTickets) | ||
| 321 | hasMore = page.cursor != nil | 341 | hasMore = page.cursor != nil |
| 322 | reconcileSelectionWithLoadedTickets() | 342 | reconcileSelectionWithLoadedTickets() |
| 323 | } catch { | 343 | } catch { |