Commit 0aff03c96c

0aff03c96cadf617e4b748eec710cfa84a45858b

parent: b2f3bce0c2

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 22:35 UTC

fix: show empty state for bare git repos instead of an error

git.sr.ht returns "internal system error" from the references query and
"reference not found" from the log/readme queries when a repo has no
commits. Broaden isEmptyRepositoryError to cover missingReference,
unknownRevision, noRows, notFound, and those two message strings, then
apply the same silent-empty treatment to loadReferences and loadArtifacts
(which previously surfaced any error directly to the user).

Fixes: https://todo.sr.ht/~ccleberg/hutch/60

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 {