krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T18:35:03Z

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
 Hutch.xcodeproj/project.pbxproj               | 16 ++++++------
 Hutch/Views/Builds/BuildListView.swift        |  1 +
 Hutch/Views/Builds/BuildListViewModel.swift   | 35 +++++++++++++++++++++------
 Hutch/Views/Builds/BuildRowView.swift         |  2 +-
 Hutch/Views/Pastes/PasteListView.swift        |  3 ++-
 Hutch/Views/Pastes/PasteListViewModel.swift   | 28 +++++++++++++++------
 Hutch/Views/Tickets/TicketListView.swift      |  6 +++--
 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