krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

30decf6d9b6b95277cbd8b8a7d9e23d9ce5413ca

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T22:35:37Z

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
 Hutch.xcodeproj/project.pbxproj                    | 16 +++++-----
 .../Repositories/RepositoryDetailViewModel.swift   | 24 +++++++++++----
 Hutch/Views/Tickets/TicketListViewModel.swift      | 34 +++++++++++++++++-----
 3 files changed, 54 insertions(+), 20 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 2d7ef6d..19792d4 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 65;
+				CURRENT_PROJECT_VERSION = 66;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.0.4;
+				MARKETING_VERSION = 3.0.5;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 65;
+				CURRENT_PROJECT_VERSION = 66;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.0.4;
+				MARKETING_VERSION = 3.0.5;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 65;
+				CURRENT_PROJECT_VERSION = 66;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.0.4;
+				MARKETING_VERSION = 3.0.5;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 65;
+				CURRENT_PROJECT_VERSION = 66;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.0.4;
+				MARKETING_VERSION = 3.0.5;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index ebd07d2..8dec51b 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -226,7 +226,7 @@ final class RepositoryDetailViewModel {
                 responseType: LogResponse.self
             )
         } catch {
-            if isMissingGitReferenceError(error) {
+            if isEmptyRepositoryError(error) {
                 return LogPage(results: [], cursor: nil)
             }
             throw error
@@ -273,7 +273,12 @@ final class RepositoryDetailViewModel {
             branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() }
             tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() }
         } catch {
-            self.error = error.userFacingMessage
+            if isEmptyRepositoryError(error) {
+                branches = []
+                tags = []
+            } else {
+                self.error = error.userFacingMessage
+            }
         }
 
         isLoadingRefs = false
@@ -340,7 +345,7 @@ final class RepositoryDetailViewModel {
                         responseType: PathResponse.self
                     )
                 } catch {
-                    if isMissingGitReferenceError(error) {
+                    if isEmptyRepositoryError(error) {
                         readmeContent = nil
                         readmePath = nil
                         readmeLoaded = true
@@ -371,8 +376,13 @@ final class RepositoryDetailViewModel {
         }
     }
 
-    private func isMissingGitReferenceError(_ error: Error) -> Bool {
+    private func isEmptyRepositoryError(_ error: Error) -> Bool {
         error.matchesGraphQLErrorClassification(.missingReference)
+            || error.matchesGraphQLErrorClassification(.unknownRevision)
+            || error.matchesGraphQLErrorClassification(.noRows)
+            || error.matchesGraphQLErrorClassification(.notFound)
+            || error.containsGraphQLErrorMessage("missing")
+            || error.containsGraphQLErrorMessage("internal system error")
     }
 
     // MARK: - Artifacts
@@ -417,7 +427,11 @@ final class RepositoryDetailViewModel {
                 .filter { !$0.artifacts.results.isEmpty }
                 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
         } catch {
-            self.error = error.userFacingMessage
+            if isEmptyRepositoryError(error) {
+                referenceArtifacts = []
+            } else {
+                self.error = error.userFacingMessage
+            }
         }
 
         isLoadingArtifacts = false
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index e23cc52..0255798 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -99,13 +99,13 @@ final class TicketListViewModel {
     var filter: TicketFilter = .open {
         didSet {
             persistFilterState()
-            updateFilteredTickets()
+            resetPaginationAndUpdateFilters()
         }
     }
     var selectedLabelIDs: Set<Int> = [] {
         didSet {
             persistFilterState()
-            updateFilteredTickets()
+            resetPaginationAndUpdateFilters()
         }
     }
     var searchText = "" {
@@ -283,6 +283,13 @@ final class TicketListViewModel {
         }
     }
 
+    private func resetPaginationAndUpdateFilters() {
+        // Reset pagination when filters change since the cursor is tied to the unfiltered dataset
+        cursor = nil
+        hasMore = true
+        updateFilteredTickets()
+    }
+
     // MARK: - Public API
 
     func loadTickets() async {
@@ -305,19 +312,32 @@ final class TicketListViewModel {
     }
 
     func loadMoreIfNeeded(currentItem: TicketSummary) async {
-        guard let last = tickets.last,
-              last.id == currentItem.id,
-              hasMore,
-              !isLoadingMore else {
+        // Check if currentItem is in the filtered list and close to the end
+        guard hasMore, !isLoadingMore else { return }
+
+        guard let index = filteredTickets.firstIndex(where: { $0.id == currentItem.id }) else {
             return
         }
 
+        let itemsFromEnd = filteredTickets.count - index - 1
+        guard itemsFromEnd < 5 else { return }
+
         isLoadingMore = true
 
         do {
             let page = try await fetchPage(cursor: cursor)
-            tickets.append(contentsOf: page.results)
+
+            // Deduplicate: only add tickets that don't already exist
+            let existingIDs = Set(tickets.map(\.id))
+            let newTickets = page.results.filter { !existingIDs.contains($0.id) }
+
+            // If we got back the same tickets, the API cursor pagination isn't working
+            if newTickets.isEmpty && !page.results.isEmpty {
+                hasMore = false
+            }
+
             cursor = page.cursor
+            tickets.append(contentsOf: newTickets)
             hasMore = page.cursor != nil
             reconcileSelectionWithLoadedTickets()
         } catch {