Commit 14fd4b36ec

14fd4b36ec5620b173638c57b94fb642650ab66a

parent: 9e6ec71086

Unsigned

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

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

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 = 61;
518 CURRENT_PROJECT_VERSION = 62;
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 = 3.0.0;
535 MARKETING_VERSION = 3.0.1;
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 = 61;
555 CURRENT_PROJECT_VERSION = 62;
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 = 3.0.0;
572 MARKETING_VERSION = 3.0.1;
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 = 61;
635 CURRENT_PROJECT_VERSION = 62;
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 = 3.0.0;
645 MARKETING_VERSION = 3.0.1;
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 = 61;
664 CURRENT_PROJECT_VERSION = 62;
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 = 3.0.0;
674 MARKETING_VERSION = 3.0.1;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildListView.swift +1
@@ -137,6 +137,7 @@ struct BuildListView: View {
137137 ForEach(viewModel.filteredJobs) { job in
138138 NavigationLink(value: job) {
139139 BuildRowView(job: job)
140 .equatable()
140141 }
141142 .contextMenu {
142143 Button {
Hutch/Views/Builds/BuildListViewModel.swift +27 −8
@@ -46,18 +46,33 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
4646final class BuildListViewModel {
4747 private static let searchHistoryScopeID = "builds"
4848
49 private(set) var jobs: [JobSummary] = []
49 private(set) var jobs: [JobSummary] = [] {
50 didSet { updateFilteredJobs() }
51 }
5052 private(set) var recentSearches: [ScopedSearchHistoryEntry]
5153 private(set) var isLoading = false
5254 private(set) var isLoadingMore = false
5355 private(set) var isRefreshing = false
5456 private(set) var isSubmitting = false
5557 var error: String?
56 var filter: BuildListFilter = .attention
57 var searchText = ""
58 var filter: BuildListFilter = .attention {
59 didSet { updateFilteredJobs() }
60 }
61 var searchText = "" {
62 didSet { updateFilteredJobs() }
63 }
5864 var repoFilter: String = "" {
59 didSet { if repoFilter != oldValue { repoFilterDidChange() } }
65 didSet {
66 guard repoFilter != oldValue else { return }
67 repoFilterDidChange()
68 updateFilteredJobs()
69 }
6070 }
71 // Cached filtered result. Updated whenever jobs, filter, searchText, or
72 // repoFilter changes. Only notifies observers when the content actually
73 // differs, which prevents the list from re-rendering on auto-refresh when
74 // no visible data changed.
75 private(set) var filteredJobs: [JobSummary] = []
6176
6277 private var cursor: String?
6378 private var hasMore = true
@@ -83,14 +98,18 @@ final class BuildListViewModel {
8398 return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
8499 }
85100
86 var filteredJobs: [JobSummary] {
101 private func updateFilteredJobs() {
87102 var result = Self.filterJobs(jobs, filter: filter)
88
89103 if !repoFilter.isEmpty {
90104 result = result.filter { $0.tags.contains(repoFilter) }
91105 }
92
93 return Self.searchJobs(result, matching: searchText)
106 let updated = Self.searchJobs(result, matching: searchText)
107 // Skip the assignment (and the resulting observer notification) when the
108 // filtered list hasn't actually changed — e.g. on auto-refresh when no
109 // builds have been added or updated.
110 if updated != filteredJobs {
111 filteredJobs = updated
112 }
94113 }
95114
96115 // MARK: - Auto-Refresh
Hutch/Views/Builds/BuildRowView.swift +1 −1
@@ -1,6 +1,6 @@
11import SwiftUI
22
3struct BuildRowView: View {
3struct BuildRowView: View, Equatable {
44 let job: JobSummary
55
66 var body: some View {
Hutch/Views/Pastes/PasteListView.swift +2 −1
@@ -73,6 +73,7 @@ struct PasteListView: View {
7373 ForEach(viewModel.filteredPastes) { paste in
7474 NavigationLink(value: paste) {
7575 PasteRowView(paste: paste)
76 .equatable()
7677 }
7778 .swipeActions(edge: .leading, allowsFullSwipe: true) {
7879 if swipeActionsEnabled {
@@ -208,7 +209,7 @@ struct PasteListView: View {
208209 }
209210}
210211
211private struct PasteRowView: View {
212private struct PasteRowView: View, Equatable {
212213 let paste: Paste
213214
214215 var body: some View {
Hutch/Views/Pastes/PasteListViewModel.swift +20 −8
@@ -3,13 +3,18 @@ import Foundation
33@Observable
44@MainActor
55final class PasteListViewModel {
6 private(set) var pastes: [Paste] = []
6 private(set) var pastes: [Paste] = [] {
7 didSet { updateFilteredPastes() }
8 }
79 private(set) var isLoading = false
810 private(set) var isLoadingMore = false
911 private(set) var isRefreshing = false
1012 private(set) var isCreatingPaste = false
1113 var error: String?
12 var searchText = ""
14 var searchText = "" {
15 didSet { updateFilteredPastes() }
16 }
17 private(set) var filteredPastes: [Paste] = []
1318
1419 private var cursor: String?
1520 private var hasMore = true
@@ -19,15 +24,22 @@ final class PasteListViewModel {
1924 self.service = service
2025 }
2126
22 var filteredPastes: [Paste] {
27 private func updateFilteredPastes() {
2328 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
24 guard !q.isEmpty else { return pastes }
25 return pastes.filter {
26 $0.files.contains {
27 ($0.filename?.lowercased().contains(q) == true) ||
28 $0.hash.lowercased().hasPrefix(q)
29 let updated: [Paste]
30 if q.isEmpty {
31 updated = pastes
32 } else {
33 updated = pastes.filter {
34 $0.files.contains {
35 ($0.filename?.lowercased().contains(q) == true) ||
36 $0.hash.lowercased().hasPrefix(q)
37 }
2938 }
3039 }
40 if updated != filteredPastes {
41 filteredPastes = updated
42 }
3143 }
3244
3345 func loadPastes() async {
Hutch/Views/Tickets/TicketListView.swift +4 −2
@@ -290,6 +290,7 @@ struct TicketListView: View {
290290 ticket: ticket,
291291 isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
292292 )
293 .equatable()
293294 }
294295 .buttonStyle(.plain)
295296 } else {
@@ -303,6 +304,7 @@ struct TicketListView: View {
303304 )
304305 } label: {
305306 TicketRowView(ticket: ticket)
307 .equatable()
306308 }
307309 .contextMenu {
308310 if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) {
@@ -1180,7 +1182,7 @@ private struct BulkAssignSheet: View {
11801182 }
11811183}
11821184
1183private struct SelectableTicketRow: View {
1185private struct SelectableTicketRow: View, Equatable {
11841186 let ticket: TicketSummary
11851187 let isSelected: Bool
11861188
@@ -1200,7 +1202,7 @@ private struct SelectableTicketRow: View {
12001202
12011203// MARK: - Ticket Row
12021204
1203private struct TicketRowView: View {
1205private struct TicketRowView: View, Equatable {
12041206 let ticket: TicketSummary
12051207
12061208 var body: some View {
Hutch/Views/Tickets/TicketListViewModel.swift +15 −5
@@ -83,7 +83,9 @@ final class TicketListViewModel {
8383 let trackerId: Int
8484 let trackerRid: String
8585
86 private(set) var tickets: [TicketSummary] = []
86 private(set) var tickets: [TicketSummary] = [] {
87 didSet { updateFilteredTickets() }
88 }
8789 private(set) var isLoading = false
8890 private(set) var isLoadingMore = false
8991 private(set) var isCreatingTicket = false
@@ -97,15 +99,21 @@ final class TicketListViewModel {
9799 var filter: TicketFilter = .open {
98100 didSet {
99101 persistFilterState()
102 updateFilteredTickets()
100103 }
101104 }
102105 var selectedLabelIDs: Set<Int> = [] {
103106 didSet {
104107 persistFilterState()
108 updateFilteredTickets()
105109 }
106110 }
107 var searchText = ""
111 var searchText = "" {
112 didSet { updateFilteredTickets() }
113 }
108114 private(set) var activeSavedFilterID: SavedTicketFilter.ID?
115 // Cached filtered result. See updateFilteredTickets().
116 private(set) var filteredTickets: [TicketSummary] = []
109117
110118 private var cursor: String?
111119 private var hasMore = true
@@ -268,9 +276,11 @@ final class TicketListViewModel {
268276 !currentFilterState.isDefault
269277 }
270278
271 /// Tickets filtered by the selected status and label filters.
272 var filteredTickets: [TicketSummary] {
273 Self.filterTickets(tickets, state: currentFilterState, query: searchText)
279 private func updateFilteredTickets() {
280 let updated = Self.filterTickets(tickets, state: currentFilterState, query: searchText)
281 if updated != filteredTickets {
282 filteredTickets = updated
283 }
274284 }
275285
276286 // MARK: - Public API