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