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 @@
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 = 60; 518 CURRENT_PROJECT_VERSION = 61;
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 = 2.21.1; 535 MARKETING_VERSION = 3.0.0;
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 = 60; 555 CURRENT_PROJECT_VERSION = 61;
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 = 2.21.1; 572 MARKETING_VERSION = 3.0.0;
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 = 60; 635 CURRENT_PROJECT_VERSION = 61;
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 = 2.21.1; 645 MARKETING_VERSION = 3.0.0;
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 = 60; 664 CURRENT_PROJECT_VERSION = 61;
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 = 2.21.1; 674 MARKETING_VERSION = 3.0.0;
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/Builds/BuildListView.swift +3 −1
@@ -108,8 +108,10 @@ struct BuildListView: View {
108 vm.repoFilter = savedRepoFilter 108 vm.repoFilter = savedRepoFilter
109 viewModel = vm 109 viewModel = vm
110 await vm.loadJobs() 110 await vm.loadJobs()
111 vm.startAutoRefresh(interval: autoRefreshInterval)
112 } 111 }
112 // Restart auto-refresh every time the view (re)appears, since
113 // onDisappear stops it when navigating away.
114 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
113 } 115 }
114 .onDisappear { 116 .onDisappear {
115 viewModel?.stopAutoRefresh() 117 viewModel?.stopAutoRefresh()
Hutch/Views/Home/HomeView.swift +1 −1
@@ -48,7 +48,7 @@ struct HomeView: View {
48 loadRecentActivity() 48 loadRecentActivity()
49 } 49 }
50 .onChange(of: scenePhase) { _, newPhase in 50 .onChange(of: scenePhase) { _, newPhase in
51 guard newPhase == .active, let viewModel else { return } 51 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
52 Task { 52 Task {
53 await viewModel.loadDashboard() 53 await viewModel.loadDashboard()
54 loadRecentActivity() 54 loadRecentActivity()
Hutch/Views/Home/HomeViewModel.swift +8
@@ -188,6 +188,7 @@ final class HomeViewModel {
188 private(set) var projectsError: String? 188 private(set) var projectsError: String?
189 private(set) var assignedTicketsError: String? 189 private(set) var assignedTicketsError: String?
190 private(set) var recentBuildsError: String? 190 private(set) var recentBuildsError: String?
191 private(set) var lastRefreshed: Date?
191 192
192 private let currentUser: User 193 private let currentUser: User
193 private let client: SRHTClient 194 private let client: SRHTClient
@@ -405,10 +406,17 @@ final class HomeViewModel {
405 systemStatusErrorMessage = error.userFacingMessage 406 systemStatusErrorMessage = error.userFacingMessage
406 } 407 }
407 isLoadingSystemStatus = false 408 isLoadingSystemStatus = false
409 lastRefreshed = Date()
408 persistNeedsAttentionSnapshot() 410 persistNeedsAttentionSnapshot()
409 persistSystemStatusWidgetSnapshot() 411 persistSystemStatusWidgetSnapshot()
410 } 412 }
411 413
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
412 var hasDashboardContent: Bool { 420 var hasDashboardContent: Bool {
413 !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil 421 !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
414 } 422 }
Hutch/Views/Repositories/RepositoryListView.swift +1 −4
@@ -88,9 +88,6 @@ struct RepositoryListView: View {
88 ) 88 )
89 } 89 }
90 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } 90 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
91 .task {
92 await viewModel.loadMoreIfNeeded(currentItem: repo)
93 }
94 } 91 }
95 92
96 if viewModel.isLoadingMore { 93 if viewModel.isLoadingMore {
@@ -156,7 +153,7 @@ struct RepositoryListView: View {
156 } 153 }
157 .srhtErrorBanner(error: $vm.error) 154 .srhtErrorBanner(error: $vm.error)
158 .refreshable { 155 .refreshable {
159 await viewModel.loadRepositories() 156 await viewModel.loadRepositories(forceRefresh: true)
160 } 157 }
161 .task { 158 .task {
162 await viewModel.loadRepositories() 159 await viewModel.loadRepositories()
Hutch/Views/Repositories/RepositoryListViewModel.swift +12 −3
@@ -46,6 +46,7 @@ final class RepositoryListViewModel {
46 private let client: SRHTClient 46 private let client: SRHTClient
47 private let defaults: UserDefaults 47 private let defaults: UserDefaults
48 private var buildStatusTask: Task<Void, Never>? 48 private var buildStatusTask: Task<Void, Never>?
49 private var lastBuildStatusRefresh: Date?
49 50
50 private static let gitCacheKey = "git.repositories" 51 private static let gitCacheKey = "git.repositories"
51 private static let hgCacheKey = "hg.repositories" 52 private static let hgCacheKey = "hg.repositories"
@@ -147,7 +148,8 @@ final class RepositoryListViewModel {
147 /// Fetch the first page of repositories. Shows cached data instantly if available, 148 /// Fetch the first page of repositories. Shows cached data instantly if available,
148 /// then refreshes from the network in the background. 149 /// then refreshes from the network in the background.
149 /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`. 150 /// - 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 {
151 let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) 153 let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
152 let isSearch = !query.isEmpty 154 let isSearch = !query.isEmpty
153 155
@@ -193,7 +195,7 @@ final class RepositoryListViewModel {
193 } 195 }
194 196
195 repositories = filteredResults.sorted(by: repositorySortOrder) 197 repositories = filteredResults.sorted(by: repositorySortOrder)
196 scheduleBuildStatusRefresh() 198 scheduleBuildStatusRefresh(force: forceRefresh)
197 } catch { 199 } catch {
198 // Only show error if we have no cached data to fall back on 200 // Only show error if we have no cached data to fall back on
199 if repositories.isEmpty { 201 if repositories.isEmpty {
@@ -583,7 +585,13 @@ final class RepositoryListViewModel {
583 } 585 }
584 } 586 }
585 587
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 }
587 let repositoriesSnapshot = repositories 595 let repositoriesSnapshot = repositories
588 buildStatusTask?.cancel() 596 buildStatusTask?.cancel()
589 buildStatusTask = Task { [weak self] in 597 buildStatusTask = Task { [weak self] in
@@ -635,6 +643,7 @@ final class RepositoryListViewModel {
635 await MainActor.run { 643 await MainActor.run {
636 guard repositories == self.repositories else { return } 644 guard repositories == self.repositories else { return }
637 latestBuildStatuses = finalStatuses 645 latestBuildStatuses = finalStatuses
646 lastBuildStatusRefresh = Date()
638 } 647 }
639 } catch { 648 } catch {
640 // Build status is auxiliary data for the list. Leave the default gray state on failure. 649 // 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 {
30 await ensureViewModel(currentUser: currentUser).loadDashboard() 30 await ensureViewModel(currentUser: currentUser).loadDashboard()
31 } 31 }
32 .onChange(of: scenePhase) { _, newPhase in 32 .onChange(of: scenePhase) { _, newPhase in
33 guard newPhase == .active, let viewModel else { return } 33 guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return }
34 Task { 34 Task {
35 await viewModel.loadDashboard() 35 await viewModel.loadDashboard()
36 } 36 }