summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 13:35:03 -0500
committerChristian Cleberg <[email protected]>2026-04-13 13:35:03 -0500
commit0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50 (patch)
treec2d92448661d9e6ca76bd7c69e9d51131305214e /Hutch
parent06b24715cd244475ded5482e926721a57278a3cf (diff)
downloadhutch-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
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Views/Builds/BuildListView.swift1
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift35
-rw-r--r--Hutch/Views/Builds/BuildRowView.swift2
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift3
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift28
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift6
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift20
7 files changed, 70 insertions, 25 deletions
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