Commit 14fd4b36ec
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 61; | |
| 518 | CURRENT_PROJECT_VERSION = 62; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 3.0.0; | |
| 535 | MARKETING_VERSION = 3.0.1; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 61; | |
| 555 | CURRENT_PROJECT_VERSION = 62; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 3.0.0; | |
| 572 | MARKETING_VERSION = 3.0.1; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 61; | |
| 635 | CURRENT_PROJECT_VERSION = 62; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 3.0.0; | |
| 645 | MARKETING_VERSION = 3.0.1; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 61; | |
| 664 | CURRENT_PROJECT_VERSION = 62; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 3.0.0; | |
| 674 | MARKETING_VERSION = 3.0.1; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Views/Builds/BuildListView.swift +1
| @@ -137,6 +137,7 @@ struct BuildListView: View { | ||
| 137 | 137 | ForEach(viewModel.filteredJobs) { job in |
| 138 | 138 | NavigationLink(value: job) { |
| 139 | 139 | BuildRowView(job: job) |
| 140 | .equatable() | |
| 140 | 141 | } |
| 141 | 142 | .contextMenu { |
| 142 | 143 | Button { |
Hutch/Views/Builds/BuildListViewModel.swift +27 −8
| @@ -46,18 +46,33 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { | ||
| 46 | 46 | final class BuildListViewModel { |
| 47 | 47 | private static let searchHistoryScopeID = "builds" |
| 48 | 48 | |
| 49 | private(set) var jobs: [JobSummary] = [] | |
| 49 | private(set) var jobs: [JobSummary] = [] { | |
| 50 | didSet { updateFilteredJobs() } | |
| 51 | } | |
| 50 | 52 | private(set) var recentSearches: [ScopedSearchHistoryEntry] |
| 51 | 53 | private(set) var isLoading = false |
| 52 | 54 | private(set) var isLoadingMore = false |
| 53 | 55 | private(set) var isRefreshing = false |
| 54 | 56 | private(set) var isSubmitting = false |
| 55 | 57 | 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 | } | |
| 58 | 64 | var repoFilter: String = "" { |
| 59 | didSet { if repoFilter != oldValue { repoFilterDidChange() } } | |
| 65 | didSet { | |
| 66 | guard repoFilter != oldValue else { return } | |
| 67 | repoFilterDidChange() | |
| 68 | updateFilteredJobs() | |
| 69 | } | |
| 60 | 70 | } |
| 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] = [] | |
| 61 | 76 | |
| 62 | 77 | private var cursor: String? |
| 63 | 78 | private var hasMore = true |
| @@ -83,14 +98,18 @@ final class BuildListViewModel { | ||
| 83 | 98 | return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } |
| 84 | 99 | } |
| 85 | 100 | |
| 86 | var filteredJobs: [JobSummary] { | |
| 101 | private func updateFilteredJobs() { | |
| 87 | 102 | var result = Self.filterJobs(jobs, filter: filter) |
| 88 | ||
| 89 | 103 | if !repoFilter.isEmpty { |
| 90 | 104 | result = result.filter { $0.tags.contains(repoFilter) } |
| 91 | 105 | } |
| 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 | } | |
| 94 | 113 | } |
| 95 | 114 | |
| 96 | 115 | // MARK: - Auto-Refresh |
Hutch/Views/Builds/BuildRowView.swift +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | struct BuildRowView: View { | |
| 3 | struct BuildRowView: View, Equatable { | |
| 4 | 4 | let job: JobSummary |
| 5 | 5 | |
| 6 | 6 | var body: some View { |
Hutch/Views/Pastes/PasteListView.swift +2 −1
| @@ -73,6 +73,7 @@ struct PasteListView: View { | ||
| 73 | 73 | ForEach(viewModel.filteredPastes) { paste in |
| 74 | 74 | NavigationLink(value: paste) { |
| 75 | 75 | PasteRowView(paste: paste) |
| 76 | .equatable() | |
| 76 | 77 | } |
| 77 | 78 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 78 | 79 | if swipeActionsEnabled { |
| @@ -208,7 +209,7 @@ struct PasteListView: View { | ||
| 208 | 209 | } |
| 209 | 210 | } |
| 210 | 211 | |
| 211 | private struct PasteRowView: View { | |
| 212 | private struct PasteRowView: View, Equatable { | |
| 212 | 213 | let paste: Paste |
| 213 | 214 | |
| 214 | 215 | var body: some View { |
Hutch/Views/Pastes/PasteListViewModel.swift +20 −8
| @@ -3,13 +3,18 @@ import Foundation | ||
| 3 | 3 | @Observable |
| 4 | 4 | @MainActor |
| 5 | 5 | final class PasteListViewModel { |
| 6 | private(set) var pastes: [Paste] = [] | |
| 6 | private(set) var pastes: [Paste] = [] { | |
| 7 | didSet { updateFilteredPastes() } | |
| 8 | } | |
| 7 | 9 | private(set) var isLoading = false |
| 8 | 10 | private(set) var isLoadingMore = false |
| 9 | 11 | private(set) var isRefreshing = false |
| 10 | 12 | private(set) var isCreatingPaste = false |
| 11 | 13 | var error: String? |
| 12 | var searchText = "" | |
| 14 | var searchText = "" { | |
| 15 | didSet { updateFilteredPastes() } | |
| 16 | } | |
| 17 | private(set) var filteredPastes: [Paste] = [] | |
| 13 | 18 | |
| 14 | 19 | private var cursor: String? |
| 15 | 20 | private var hasMore = true |
| @@ -19,15 +24,22 @@ final class PasteListViewModel { | ||
| 19 | 24 | self.service = service |
| 20 | 25 | } |
| 21 | 26 | |
| 22 | var filteredPastes: [Paste] { | |
| 27 | private func updateFilteredPastes() { | |
| 23 | 28 | 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 | } | |
| 29 | 38 | } |
| 30 | 39 | } |
| 40 | if updated != filteredPastes { | |
| 41 | filteredPastes = updated | |
| 42 | } | |
| 31 | 43 | } |
| 32 | 44 | |
| 33 | 45 | func loadPastes() async { |
Hutch/Views/Tickets/TicketListView.swift +4 −2
| @@ -290,6 +290,7 @@ struct TicketListView: View { | ||
| 290 | 290 | ticket: ticket, |
| 291 | 291 | isSelected: viewModel.selectedTicketIDs.contains(ticket.id) |
| 292 | 292 | ) |
| 293 | .equatable() | |
| 293 | 294 | } |
| 294 | 295 | .buttonStyle(.plain) |
| 295 | 296 | } else { |
| @@ -303,6 +304,7 @@ struct TicketListView: View { | ||
| 303 | 304 | ) |
| 304 | 305 | } label: { |
| 305 | 306 | TicketRowView(ticket: ticket) |
| 307 | .equatable() | |
| 306 | 308 | } |
| 307 | 309 | .contextMenu { |
| 308 | 310 | if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) { |
| @@ -1180,7 +1182,7 @@ private struct BulkAssignSheet: View { | ||
| 1180 | 1182 | } |
| 1181 | 1183 | } |
| 1182 | 1184 | |
| 1183 | private struct SelectableTicketRow: View { | |
| 1185 | private struct SelectableTicketRow: View, Equatable { | |
| 1184 | 1186 | let ticket: TicketSummary |
| 1185 | 1187 | let isSelected: Bool |
| 1186 | 1188 | |
| @@ -1200,7 +1202,7 @@ private struct SelectableTicketRow: View { | ||
| 1200 | 1202 | |
| 1201 | 1203 | // MARK: - Ticket Row |
| 1202 | 1204 | |
| 1203 | private struct TicketRowView: View { | |
| 1205 | private struct TicketRowView: View, Equatable { | |
| 1204 | 1206 | let ticket: TicketSummary |
| 1205 | 1207 | |
| 1206 | 1208 | var body: some View { |
Hutch/Views/Tickets/TicketListViewModel.swift +15 −5
| @@ -83,7 +83,9 @@ final class TicketListViewModel { | ||
| 83 | 83 | let trackerId: Int |
| 84 | 84 | let trackerRid: String |
| 85 | 85 | |
| 86 | private(set) var tickets: [TicketSummary] = [] | |
| 86 | private(set) var tickets: [TicketSummary] = [] { | |
| 87 | didSet { updateFilteredTickets() } | |
| 88 | } | |
| 87 | 89 | private(set) var isLoading = false |
| 88 | 90 | private(set) var isLoadingMore = false |
| 89 | 91 | private(set) var isCreatingTicket = false |
| @@ -97,15 +99,21 @@ final class TicketListViewModel { | ||
| 97 | 99 | var filter: TicketFilter = .open { |
| 98 | 100 | didSet { |
| 99 | 101 | persistFilterState() |
| 102 | updateFilteredTickets() | |
| 100 | 103 | } |
| 101 | 104 | } |
| 102 | 105 | var selectedLabelIDs: Set<Int> = [] { |
| 103 | 106 | didSet { |
| 104 | 107 | persistFilterState() |
| 108 | updateFilteredTickets() | |
| 105 | 109 | } |
| 106 | 110 | } |
| 107 | var searchText = "" | |
| 111 | var searchText = "" { | |
| 112 | didSet { updateFilteredTickets() } | |
| 113 | } | |
| 108 | 114 | private(set) var activeSavedFilterID: SavedTicketFilter.ID? |
| 115 | // Cached filtered result. See updateFilteredTickets(). | |
| 116 | private(set) var filteredTickets: [TicketSummary] = [] | |
| 109 | 117 | |
| 110 | 118 | private var cursor: String? |
| 111 | 119 | private var hasMore = true |
| @@ -268,9 +276,11 @@ final class TicketListViewModel { | ||
| 268 | 276 | !currentFilterState.isDefault |
| 269 | 277 | } |
| 270 | 278 | |
| 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 | } | |
| 274 | 284 | } |
| 275 | 285 | |
| 276 | 286 | // MARK: - Public API |