Commit 9e6ec71086

9e6ec710861994439ab9eb56949da4f7ffa512ff

parent: 8fc97b98ae

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 18:26 UTC

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

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 60;
518 CURRENT_PROJECT_VERSION = 61;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.21.1;
535 MARKETING_VERSION = 3.0.0;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 60;
555 CURRENT_PROJECT_VERSION = 61;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.21.1;
572 MARKETING_VERSION = 3.0.0;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 60;
635 CURRENT_PROJECT_VERSION = 61;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.21.1;
645 MARKETING_VERSION = 3.0.0;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 60;
664 CURRENT_PROJECT_VERSION = 61;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.21.1;
674 MARKETING_VERSION = 3.0.0;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildListView.swift +3 −1
@@ -108,8 +108,10 @@ struct BuildListView: View {
108108 vm.repoFilter = savedRepoFilter
109109 viewModel = vm
110110 await vm.loadJobs()
111 vm.startAutoRefresh(interval: autoRefreshInterval)
112111 }
112 // Restart auto-refresh every time the view (re)appears, since
113 // onDisappear stops it when navigating away.
114 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
113115 }
114116 .onDisappear {
115117 viewModel?.stopAutoRefresh()
Hutch/Views/Home/HomeView.swift +1 −1
@@ -48,7 +48,7 @@ struct HomeView: View {
4848 loadRecentActivity()
4949 }
5050 .onChange(of: scenePhase) { _, newPhase in
51 guard newPhase == .active, let viewModel else { return }
51 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
5252 Task {
5353 await viewModel.loadDashboard()
5454 loadRecentActivity()
Hutch/Views/Home/HomeViewModel.swift +8
@@ -188,6 +188,7 @@ final class HomeViewModel {
188188 private(set) var projectsError: String?
189189 private(set) var assignedTicketsError: String?
190190 private(set) var recentBuildsError: String?
191 private(set) var lastRefreshed: Date?
191192
192193 private let currentUser: User
193194 private let client: SRHTClient
@@ -405,10 +406,17 @@ final class HomeViewModel {
405406 systemStatusErrorMessage = error.userFacingMessage
406407 }
407408 isLoadingSystemStatus = false
409 lastRefreshed = Date()
408410 persistNeedsAttentionSnapshot()
409411 persistSystemStatusWidgetSnapshot()
410412 }
411413
414 /// Returns true if sufficient time has elapsed since the last dashboard refresh.
415 func needsRefresh(after interval: TimeInterval = 60) -> Bool {
416 guard let lastRefreshed else { return true }
417 return Date().timeIntervalSince(lastRefreshed) > interval
418 }
419
412420 var hasDashboardContent: Bool {
413421 !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
414422 }
Hutch/Views/Repositories/RepositoryListView.swift +1 −4
@@ -88,9 +88,6 @@ struct RepositoryListView: View {
8888 )
8989 }
9090 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
91 .task {
92 await viewModel.loadMoreIfNeeded(currentItem: repo)
93 }
9491 }
9592
9693 if viewModel.isLoadingMore {
@@ -156,7 +153,7 @@ struct RepositoryListView: View {
156153 }
157154 .srhtErrorBanner(error: $vm.error)
158155 .refreshable {
159 await viewModel.loadRepositories()
156 await viewModel.loadRepositories(forceRefresh: true)
160157 }
161158 .task {
162159 await viewModel.loadRepositories()
Hutch/Views/Repositories/RepositoryListViewModel.swift +12 −3
@@ -46,6 +46,7 @@ final class RepositoryListViewModel {
4646 private let client: SRHTClient
4747 private let defaults: UserDefaults
4848 private var buildStatusTask: Task<Void, Never>?
49 private var lastBuildStatusRefresh: Date?
4950
5051 private static let gitCacheKey = "git.repositories"
5152 private static let hgCacheKey = "hg.repositories"
@@ -147,7 +148,8 @@ final class RepositoryListViewModel {
147148 /// Fetch the first page of repositories. Shows cached data instantly if available,
148149 /// then refreshes from the network in the background.
149150 /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
150 func loadRepositories(search: String? = nil) async {
151 /// - Parameter forceRefresh: When true, bypass the build-status TTL (e.g. pull-to-refresh).
152 func loadRepositories(search: String? = nil, forceRefresh: Bool = false) async {
151153 let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
152154 let isSearch = !query.isEmpty
153155
@@ -193,7 +195,7 @@ final class RepositoryListViewModel {
193195 }
194196
195197 repositories = filteredResults.sorted(by: repositorySortOrder)
196 scheduleBuildStatusRefresh()
198 scheduleBuildStatusRefresh(force: forceRefresh)
197199 } catch {
198200 // Only show error if we have no cached data to fall back on
199201 if repositories.isEmpty {
@@ -583,7 +585,13 @@ final class RepositoryListViewModel {
583585 }
584586 }
585587
586 private func scheduleBuildStatusRefresh() {
588 private func scheduleBuildStatusRefresh(force: Bool = false) {
589 // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
590 // passes force: true to bypass this check.
591 if !force, let last = lastBuildStatusRefresh,
592 Date().timeIntervalSince(last) < 120 {
593 return
594 }
587595 let repositoriesSnapshot = repositories
588596 buildStatusTask?.cancel()
589597 buildStatusTask = Task { [weak self] in
@@ -635,6 +643,7 @@ final class RepositoryListViewModel {
635643 await MainActor.run {
636644 guard repositories == self.repositories else { return }
637645 latestBuildStatuses = finalStatuses
646 lastBuildStatusRefresh = Date()
638647 }
639648 } catch {
640649 // Build status is auxiliary data for the list. Leave the default gray state on failure.
Hutch/Views/Work/WorkView.swift +1 −1
@@ -30,7 +30,7 @@ struct WorkView: View {
3030 await ensureViewModel(currentUser: currentUser).loadDashboard()
3131 }
3232 .onChange(of: scenePhase) { _, newPhase in
33 guard newPhase == .active, let viewModel else { return }
33 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
3434 Task {
3535 await viewModel.loadDashboard()
3636 }