summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 13:26:08 -0500
committerChristian Cleberg <[email protected]>2026-04-13 13:26:08 -0500
commit06b24715cd244475ded5482e926721a57278a3cf (patch)
tree62eb3017a6bf42eb1bd061f09fcf231421ecc107
parentf5f5757d0e1b78470429ae7cb9f742c95662849b (diff)
downloadhutch-06b24715cd244475ded5482e926721a57278a3cf.tar.gz
hutch-06b24715cd244475ded5482e926721a57278a3cf.tar.bz2
hutch-06b24715cd244475ded5482e926721a57278a3cf.zip
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
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Views/Builds/BuildListView.swift4
-rw-r--r--Hutch/Views/Home/HomeView.swift2
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift8
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift5
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift15
-rw-r--r--Hutch/Views/Work/WorkView.swift2
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()
}