krz/hutch

an ios client for sourcehut

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

06b24715cd244475ded5482e926721a57278a3cf

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T18:26:08Z

perf: reduce redundant fetches and improve list render efficiency

- Remove no-op per-row task from RepositoryListView; loadMoreIfNeeded
  is a stub for repos so each row was allocating a Task that did nothing
- Fix BuildListView auto-refresh stopping permanently after navigating
  away; startAutoRefresh now runs unconditionally on task so it restarts
  on every reappear, not just first load
- Add lastRefreshed tracking to HomeViewModel with a needsRefresh(after:)
  helper; HomeView and WorkView scene-activation handlers now skip
  loadDashboard() if the data is less than 60 seconds old
- Add 120-second TTL to repository build status refresh; statuses are
  no longer re-fetched on every tab appear, only when stale or when the
  user explicitly pulls to refresh (forceRefresh: true)

Implements: https://todo.sr.ht/~ccleberg/hutch/56
 Hutch.xcodeproj/project.pbxproj                        | 16 ++++++++--------
 Hutch/Views/Builds/BuildListView.swift                 |  4 +++-
 Hutch/Views/Home/HomeView.swift                        |  2 +-
 Hutch/Views/Home/HomeViewModel.swift                   |  8 ++++++++
 Hutch/Views/Repositories/RepositoryListView.swift      |  5 +----
 Hutch/Views/Repositories/RepositoryListViewModel.swift | 15 ++++++++++++---
 Hutch/Views/Work/WorkView.swift                        |  2 +-
 7 files changed, 34 insertions(+), 18 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index f1e1869..f2a08a8 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 = 60;
+				CURRENT_PROJECT_VERSION = 61;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.21.1;
+				MARKETING_VERSION = 3.0.0;
 				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 = 60;
+				CURRENT_PROJECT_VERSION = 61;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.21.1;
+				MARKETING_VERSION = 3.0.0;
 				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 = 60;
+				CURRENT_PROJECT_VERSION = 61;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.21.1;
+				MARKETING_VERSION = 3.0.0;
 				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 = 60;
+				CURRENT_PROJECT_VERSION = 61;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.21.1;
+				MARKETING_VERSION = 3.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index aa98066..6f3f934 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -108,8 +108,10 @@ struct BuildListView: View {
                 vm.repoFilter = savedRepoFilter
                 viewModel = vm
                 await vm.loadJobs()
-                vm.startAutoRefresh(interval: autoRefreshInterval)
             }
+            // Restart auto-refresh every time the view (re)appears, since
+            // onDisappear stops it when navigating away.
+            viewModel?.startAutoRefresh(interval: autoRefreshInterval)
         }
         .onDisappear {
             viewModel?.stopAutoRefresh()
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 1a68498..105b62b 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -48,7 +48,7 @@ struct HomeView: View {
             loadRecentActivity()
         }
         .onChange(of: scenePhase) { _, newPhase in
-            guard newPhase == .active, let viewModel else { return }
+            guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
             Task {
                 await viewModel.loadDashboard()
                 loadRecentActivity()
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index f0c14dd..ec54759 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -188,6 +188,7 @@ final class HomeViewModel {
     private(set) var projectsError: String?
     private(set) var assignedTicketsError: String?
     private(set) var recentBuildsError: String?
+    private(set) var lastRefreshed: Date?
 
     private let currentUser: User
     private let client: SRHTClient
@@ -405,10 +406,17 @@ final class HomeViewModel {
             systemStatusErrorMessage = error.userFacingMessage
         }
         isLoadingSystemStatus = false
+        lastRefreshed = Date()
         persistNeedsAttentionSnapshot()
         persistSystemStatusWidgetSnapshot()
     }
 
+    /// Returns true if sufficient time has elapsed since the last dashboard refresh.
+    func needsRefresh(after interval: TimeInterval = 60) -> Bool {
+        guard let lastRefreshed else { return true }
+        return Date().timeIntervalSince(lastRefreshed) > interval
+    }
+
     var hasDashboardContent: Bool {
         !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
     }
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index 68dd0b0..0258a58 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -88,9 +88,6 @@ struct RepositoryListView: View {
                     )
                 }
                 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
-                .task {
-                    await viewModel.loadMoreIfNeeded(currentItem: repo)
-                }
             }
 
             if viewModel.isLoadingMore {
@@ -156,7 +153,7 @@ struct RepositoryListView: View {
         }
         .srhtErrorBanner(error: $vm.error)
         .refreshable {
-            await viewModel.loadRepositories()
+            await viewModel.loadRepositories(forceRefresh: true)
         }
         .task {
             await viewModel.loadRepositories()
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 0cfc8c3..1e830bb 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -46,6 +46,7 @@ final class RepositoryListViewModel {
     private let client: SRHTClient
     private let defaults: UserDefaults
     private var buildStatusTask: Task<Void, Never>?
+    private var lastBuildStatusRefresh: Date?
 
     private static let gitCacheKey = "git.repositories"
     private static let hgCacheKey = "hg.repositories"
@@ -147,7 +148,8 @@ final class RepositoryListViewModel {
     /// Fetch the first page of repositories. Shows cached data instantly if available,
     /// then refreshes from the network in the background.
     /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
-    func loadRepositories(search: String? = nil) async {
+    /// - Parameter forceRefresh: When true, bypass the build-status TTL (e.g. pull-to-refresh).
+    func loadRepositories(search: String? = nil, forceRefresh: Bool = false) async {
         let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
         let isSearch = !query.isEmpty
 
@@ -193,7 +195,7 @@ final class RepositoryListViewModel {
             }
 
             repositories = filteredResults.sorted(by: repositorySortOrder)
-            scheduleBuildStatusRefresh()
+            scheduleBuildStatusRefresh(force: forceRefresh)
         } catch {
             // Only show error if we have no cached data to fall back on
             if repositories.isEmpty {
@@ -583,7 +585,13 @@ final class RepositoryListViewModel {
         }
     }
 
-    private func scheduleBuildStatusRefresh() {
+    private func scheduleBuildStatusRefresh(force: Bool = false) {
+        // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
+        // passes force: true to bypass this check.
+        if !force, let last = lastBuildStatusRefresh,
+           Date().timeIntervalSince(last) < 120 {
+            return
+        }
         let repositoriesSnapshot = repositories
         buildStatusTask?.cancel()
         buildStatusTask = Task { [weak self] in
@@ -635,6 +643,7 @@ final class RepositoryListViewModel {
             await MainActor.run {
                 guard repositories == self.repositories else { return }
                 latestBuildStatuses = finalStatuses
+                lastBuildStatusRefresh = Date()
             }
         } catch {
             // Build status is auxiliary data for the list. Leave the default gray state on failure.
diff --git a/Hutch/Views/Work/WorkView.swift b/Hutch/Views/Work/WorkView.swift
index 1e684d4..a7fbd1c 100644
--- a/Hutch/Views/Work/WorkView.swift
+++ b/Hutch/Views/Work/WorkView.swift
@@ -30,7 +30,7 @@ struct WorkView: View {
             await ensureViewModel(currentUser: currentUser).loadDashboard()
         }
         .onChange(of: scenePhase) { _, newPhase in
-            guard newPhase == .active, let viewModel else { return }
+            guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
             Task {
                 await viewModel.loadDashboard()
             }