diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 13:35:03 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 13:35:03 -0500 |
| commit | 0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50 (patch) | |
| tree | c2d92448661d9e6ca76bd7c69e9d51131305214e | |
| parent | 06b24715cd244475ded5482e926721a57278a3cf (diff) | |
| download | hutch-0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50.tar.gz hutch-0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50.tar.bz2 hutch-0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50.zip | |
perf: cache filtered collections and add equatable row views
Convert filteredJobs, filteredPastes, and filteredTickets from computed
properties to stored properties updated via didSet on their inputs. Each
update function guards with an equality check before assigning, so
@Observable skips the notification when the filtered result hasn't
changed — preventing list re-renders on auto-refresh when no visible
data has actually changed.
Add Equatable conformance to BuildRowView, PasteRowView, TicketRowView,
and SelectableTicketRow, and apply .equatable() at each ForEach call
site. When a list does re-render, SwiftUI now skips body evaluation for
rows whose model value is identical to the previous pass.
Implements: https://todo.sr.ht/~ccleberg/hutch/57
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 1 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 35 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildRowView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListViewModel.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 20 |
8 files changed, 78 insertions, 33 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index f2a08a8..e8b6b96 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 = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 3.0.1; 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 = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0.0; + MARKETING_VERSION = 3.0.1; 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 = 61; + CURRENT_PROJECT_VERSION = 62; 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.0; + MARKETING_VERSION = 3.0.1; 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 = 61; + CURRENT_PROJECT_VERSION = 62; 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.0; + MARKETING_VERSION = 3.0.1; 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 6f3f934..d525918 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -137,6 +137,7 @@ struct BuildListView: View { ForEach(viewModel.filteredJobs) { job in NavigationLink(value: job) { BuildRowView(job: job) + .equatable() } .contextMenu { Button { diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index c6f8e49..643bacd 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -46,18 +46,33 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { final class BuildListViewModel { private static let searchHistoryScopeID = "builds" - private(set) var jobs: [JobSummary] = [] + private(set) var jobs: [JobSummary] = [] { + didSet { updateFilteredJobs() } + } private(set) var recentSearches: [ScopedSearchHistoryEntry] private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var isRefreshing = false private(set) var isSubmitting = false var error: String? - var filter: BuildListFilter = .attention - var searchText = "" + var filter: BuildListFilter = .attention { + didSet { updateFilteredJobs() } + } + var searchText = "" { + didSet { updateFilteredJobs() } + } var repoFilter: String = "" { - didSet { if repoFilter != oldValue { repoFilterDidChange() } } + didSet { + guard repoFilter != oldValue else { return } + repoFilterDidChange() + updateFilteredJobs() + } } + // Cached filtered result. Updated whenever jobs, filter, searchText, or + // repoFilter changes. Only notifies observers when the content actually + // differs, which prevents the list from re-rendering on auto-refresh when + // no visible data changed. + private(set) var filteredJobs: [JobSummary] = [] private var cursor: String? private var hasMore = true @@ -83,14 +98,18 @@ final class BuildListViewModel { return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } - var filteredJobs: [JobSummary] { + private func updateFilteredJobs() { var result = Self.filterJobs(jobs, filter: filter) - if !repoFilter.isEmpty { result = result.filter { $0.tags.contains(repoFilter) } } - - return Self.searchJobs(result, matching: searchText) + let updated = Self.searchJobs(result, matching: searchText) + // Skip the assignment (and the resulting observer notification) when the + // filtered list hasn't actually changed — e.g. on auto-refresh when no + // builds have been added or updated. + if updated != filteredJobs { + filteredJobs = updated + } } // MARK: - Auto-Refresh diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift index 9602e5c..a490123 100644 --- a/Hutch/Views/Builds/BuildRowView.swift +++ b/Hutch/Views/Builds/BuildRowView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct BuildRowView: View { +struct BuildRowView: View, Equatable { let job: JobSummary var body: some View { diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift index 0fdddd7..9363b08 100644 --- a/Hutch/Views/Pastes/PasteListView.swift +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -73,6 +73,7 @@ struct PasteListView: View { ForEach(viewModel.filteredPastes) { paste in NavigationLink(value: paste) { PasteRowView(paste: paste) + .equatable() } .swipeActions(edge: .leading, allowsFullSwipe: true) { if swipeActionsEnabled { @@ -208,7 +209,7 @@ struct PasteListView: View { } } -private struct PasteRowView: View { +private struct PasteRowView: View, Equatable { let paste: Paste var body: some View { diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift index b521e18..75f2f4b 100644 --- a/Hutch/Views/Pastes/PasteListViewModel.swift +++ b/Hutch/Views/Pastes/PasteListViewModel.swift @@ -3,13 +3,18 @@ import Foundation @Observable @MainActor final class PasteListViewModel { - private(set) var pastes: [Paste] = [] + private(set) var pastes: [Paste] = [] { + didSet { updateFilteredPastes() } + } private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var isRefreshing = false private(set) var isCreatingPaste = false var error: String? - var searchText = "" + var searchText = "" { + didSet { updateFilteredPastes() } + } + private(set) var filteredPastes: [Paste] = [] private var cursor: String? private var hasMore = true @@ -19,15 +24,22 @@ final class PasteListViewModel { self.service = service } - var filteredPastes: [Paste] { + private func updateFilteredPastes() { let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return pastes } - return pastes.filter { - $0.files.contains { - ($0.filename?.lowercased().contains(q) == true) || - $0.hash.lowercased().hasPrefix(q) + let updated: [Paste] + if q.isEmpty { + updated = pastes + } else { + updated = pastes.filter { + $0.files.contains { + ($0.filename?.lowercased().contains(q) == true) || + $0.hash.lowercased().hasPrefix(q) + } } } + if updated != filteredPastes { + filteredPastes = updated + } } func loadPastes() async { diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 8f7d348..e58d194 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -290,6 +290,7 @@ struct TicketListView: View { ticket: ticket, isSelected: viewModel.selectedTicketIDs.contains(ticket.id) ) + .equatable() } .buttonStyle(.plain) } else { @@ -303,6 +304,7 @@ struct TicketListView: View { ) } label: { TicketRowView(ticket: ticket) + .equatable() } .contextMenu { if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) { @@ -1180,7 +1182,7 @@ private struct BulkAssignSheet: View { } } -private struct SelectableTicketRow: View { +private struct SelectableTicketRow: View, Equatable { let ticket: TicketSummary let isSelected: Bool @@ -1200,7 +1202,7 @@ private struct SelectableTicketRow: View { // MARK: - Ticket Row -private struct TicketRowView: View { +private struct TicketRowView: View, Equatable { let ticket: TicketSummary var body: some View { diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index e7cc565..e23cc52 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -83,7 +83,9 @@ final class TicketListViewModel { let trackerId: Int let trackerRid: String - private(set) var tickets: [TicketSummary] = [] + private(set) var tickets: [TicketSummary] = [] { + didSet { updateFilteredTickets() } + } private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var isCreatingTicket = false @@ -97,15 +99,21 @@ final class TicketListViewModel { var filter: TicketFilter = .open { didSet { persistFilterState() + updateFilteredTickets() } } var selectedLabelIDs: Set<Int> = [] { didSet { persistFilterState() + updateFilteredTickets() } } - var searchText = "" + var searchText = "" { + didSet { updateFilteredTickets() } + } private(set) var activeSavedFilterID: SavedTicketFilter.ID? + // Cached filtered result. See updateFilteredTickets(). + private(set) var filteredTickets: [TicketSummary] = [] private var cursor: String? private var hasMore = true @@ -268,9 +276,11 @@ final class TicketListViewModel { !currentFilterState.isDefault } - /// Tickets filtered by the selected status and label filters. - var filteredTickets: [TicketSummary] { - Self.filterTickets(tickets, state: currentFilterState, query: searchText) + private func updateFilteredTickets() { + let updated = Self.filterTickets(tickets, state: currentFilterState, query: searchText) + if updated != filteredTickets { + filteredTickets = updated + } } // MARK: - Public API |
