Commit d18aa66063
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 = 46; | |
| 518 | CURRENT_PROJECT_VERSION = 49; | |
| 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 = 2.19.3; | |
| 535 | MARKETING_VERSION = 2.20.0; | |
| 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 = 46; | |
| 555 | CURRENT_PROJECT_VERSION = 49; | |
| 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 = 2.19.3; | |
| 572 | MARKETING_VERSION = 2.20.0; | |
| 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 = 46; | |
| 635 | CURRENT_PROJECT_VERSION = 49; | |
| 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 = 2.19.3; | |
| 645 | MARKETING_VERSION = 2.20.0; | |
| 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 = 46; | |
| 664 | CURRENT_PROJECT_VERSION = 49; | |
| 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 = 2.19.3; | |
| 674 | MARKETING_VERSION = 2.20.0; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +1
| @@ -5,6 +5,7 @@ enum AppStorageKeys { | ||
| 5 | 5 | static let activeAccountID = "activeAccountID" |
| 6 | 6 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" |
| 7 | 7 | static let lookupHistory = "lookupHistory" |
| 8 | static let scopedSearchHistory = "scopedSearchHistory" | |
| 8 | 9 | static let hutchStatsBaseURL = "hutchStatsBaseURL" |
| 9 | 10 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" |
| 10 | 11 | static let systemStatusIncidentCache = "systemStatusIncidentCache" |
Hutch/Views/Builds/BuildListView.swift +25 −3
| @@ -167,8 +167,25 @@ struct BuildListView: View { | ||
| 167 | 167 | .searchable( |
| 168 | 168 | text: $vm.searchText, |
| 169 | 169 | placement: .navigationBarDrawer(displayMode: .always), |
| 170 | prompt: "Search builds" | |
| 170 | prompt: "Search builds by job ID, tag, note, or status" | |
| 171 | 171 | ) |
| 172 | .searchSuggestions { | |
| 173 | if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 174 | RecentSearchSuggestions( | |
| 175 | title: "Recent Build Searches", | |
| 176 | entries: viewModel.recentSearches | |
| 177 | ) { query in | |
| 178 | vm.searchText = query | |
| 179 | } onClear: { | |
| 180 | viewModel.clearRecentSearches() | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | .onSubmit(of: .search) { | |
| 185 | let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 186 | guard !query.isEmpty else { return } | |
| 187 | viewModel.recordRecentSearch(query) | |
| 188 | } | |
| 172 | 189 | .overlay { |
| 173 | 190 | if viewModel.isLoading, viewModel.jobs.isEmpty { |
| 174 | 191 | SRHTLoadingStateView(message: "Loading builds…") |
| @@ -178,8 +195,13 @@ struct BuildListView: View { | ||
| 178 | 195 | message: error, |
| 179 | 196 | retryAction: { await viewModel.loadJobs() } |
| 180 | 197 | ) |
| 181 | } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { | |
| 182 | ContentUnavailableView.search(text: viewModel.searchText) | |
| 198 | } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, | |
| 199 | viewModel.filteredJobs.isEmpty { | |
| 200 | ContentUnavailableView( | |
| 201 | "No Build Matches", | |
| 202 | systemImage: "magnifyingglass", | |
| 203 | description: Text("No builds matched “\(viewModel.searchText)”.") | |
| 204 | ) | |
| 183 | 205 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { |
| 184 | 206 | ContentUnavailableView( |
| 185 | 207 | "No Builds", |
Hutch/Views/Builds/BuildListViewModel.swift +43 −9
| @@ -44,8 +44,10 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { | ||
| 44 | 44 | @Observable |
| 45 | 45 | @MainActor |
| 46 | 46 | final class BuildListViewModel { |
| 47 | private static let searchHistoryScopeID = "builds" | |
| 47 | 48 | |
| 48 | 49 | private(set) var jobs: [JobSummary] = [] |
| 50 | private(set) var recentSearches: [ScopedSearchHistoryEntry] | |
| 49 | 51 | private(set) var isLoading = false |
| 50 | 52 | private(set) var isLoadingMore = false |
| 51 | 53 | private(set) var isRefreshing = false |
| @@ -60,13 +62,19 @@ final class BuildListViewModel { | ||
| 60 | 62 | private var cursor: String? |
| 61 | 63 | private var hasMore = true |
| 62 | 64 | private let client: SRHTClient |
| 65 | private let defaults: UserDefaults | |
| 63 | 66 | private var refreshTask: Task<Void, Never>? |
| 64 | 67 | private var isAutoRefreshing = false |
| 65 | 68 | |
| 66 | 69 | private static let cacheKey = "builds.jobs" |
| 67 | 70 | |
| 68 | init(client: SRHTClient) { | |
| 71 | init(client: SRHTClient, defaults: UserDefaults = .standard) { | |
| 69 | 72 | self.client = client |
| 73 | self.defaults = defaults | |
| 74 | self.recentSearches = ScopedSearchHistoryStore.load( | |
| 75 | scopeID: Self.searchHistoryScopeID, | |
| 76 | defaults: defaults | |
| 77 | ) | |
| 70 | 78 | } |
| 71 | 79 | |
| 72 | 80 | /// Unique tags across all loaded jobs, sorted alphabetically. |
| @@ -82,14 +90,7 @@ final class BuildListViewModel { | ||
| 82 | 90 | result = result.filter { $0.tags.contains(repoFilter) } |
| 83 | 91 | } |
| 84 | 92 | |
| 85 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 86 | guard !q.isEmpty else { return result } | |
| 87 | return result.filter { | |
| 88 | String($0.id).contains(q) || | |
| 89 | $0.tags.contains { $0.lowercased().contains(q) } || | |
| 90 | ($0.note?.lowercased().contains(q) == true) || | |
| 91 | ($0.image?.lowercased().contains(q) == true) | |
| 92 | } | |
| 93 | return Self.searchJobs(result, matching: searchText) | |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | // MARK: - Auto-Refresh |
| @@ -291,6 +292,26 @@ final class BuildListViewModel { | ||
| 291 | 292 | } |
| 292 | 293 | } |
| 293 | 294 | |
| 295 | func recordRecentSearch(_ query: String) { | |
| 296 | ScopedSearchHistoryStore.record( | |
| 297 | query: query, | |
| 298 | scopeID: Self.searchHistoryScopeID, | |
| 299 | defaults: defaults | |
| 300 | ) | |
| 301 | recentSearches = ScopedSearchHistoryStore.load( | |
| 302 | scopeID: Self.searchHistoryScopeID, | |
| 303 | defaults: defaults | |
| 304 | ) | |
| 305 | } | |
| 306 | ||
| 307 | func clearRecentSearches() { | |
| 308 | ScopedSearchHistoryStore.clear( | |
| 309 | scopeID: Self.searchHistoryScopeID, | |
| 310 | defaults: defaults | |
| 311 | ) | |
| 312 | recentSearches = [] | |
| 313 | } | |
| 314 | ||
| 294 | 315 | // MARK: - Private |
| 295 | 316 | |
| 296 | 317 | private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { |
| @@ -363,4 +384,17 @@ final class BuildListViewModel { | ||
| 363 | 384 | } |
| 364 | 385 | } |
| 365 | 386 | } |
| 387 | ||
| 388 | nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { | |
| 389 | let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 390 | guard !normalizedQuery.isEmpty else { return jobs } | |
| 391 | ||
| 392 | return jobs.filter { | |
| 393 | String($0.id).contains(normalizedQuery) || | |
| 394 | $0.status.rawValue.lowercased().contains(normalizedQuery) || | |
| 395 | $0.tags.contains { $0.lowercased().contains(normalizedQuery) } || | |
| 396 | ($0.note?.lowercased().contains(normalizedQuery) == true) || | |
| 397 | ($0.image?.lowercased().contains(normalizedQuery) == true) | |
| 398 | } | |
| 399 | } | |
| 366 | 400 | } |
Hutch/Views/Repositories/RepositoryListView.swift +51 −2
| @@ -4,6 +4,7 @@ struct RepositoryListView: View { | ||
| 4 | 4 | @Environment(AppState.self) private var appState |
| 5 | 5 | @State private var viewModel: RepositoryListViewModel? |
| 6 | 6 | @State private var searchTask: Task<Void, Never>? |
| 7 | @State private var immediateSearchTask: Task<Void, Never>? | |
| 7 | 8 | @State private var showCreateRepositorySheet = false |
| 8 | 9 | @State private var createdRepository: RepositorySummary? |
| 9 | 10 | |
| @@ -68,6 +69,17 @@ struct RepositoryListView: View { | ||
| 68 | 69 | @Bindable var vm = viewModel |
| 69 | 70 | |
| 70 | 71 | List { |
| 72 | if viewModel.isSearching { | |
| 73 | HStack(spacing: 12) { | |
| 74 | ProgressView() | |
| 75 | .controlSize(.small) | |
| 76 | Text("Searching repositories…") | |
| 77 | .font(.footnote) | |
| 78 | .foregroundStyle(.secondary) | |
| 79 | } | |
| 80 | .listRowSeparator(.hidden) | |
| 81 | } | |
| 82 | ||
| 71 | 83 | ForEach(viewModel.repositories) { repo in |
| 72 | 84 | NavigationLink(value: repo) { |
| 73 | 85 | RepositoryRowView( |
| @@ -92,7 +104,28 @@ struct RepositoryListView: View { | ||
| 92 | 104 | } |
| 93 | 105 | .themedList() |
| 94 | 106 | .listStyle(.plain) |
| 95 | .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories") | |
| 107 | .searchable( | |
| 108 | text: $vm.searchText, | |
| 109 | placement: .navigationBarDrawer(displayMode: .always), | |
| 110 | prompt: "Search repositories by name, owner, or description" | |
| 111 | ) | |
| 112 | .searchSuggestions { | |
| 113 | if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 114 | RecentSearchSuggestions( | |
| 115 | title: "Recent Repository Searches", | |
| 116 | entries: viewModel.recentSearches | |
| 117 | ) { query in | |
| 118 | searchTask?.cancel() | |
| 119 | immediateSearchTask?.cancel() | |
| 120 | vm.searchText = query | |
| 121 | immediateSearchTask = Task { | |
| 122 | await viewModel.loadRepositories(search: query) | |
| 123 | } | |
| 124 | } onClear: { | |
| 125 | viewModel.clearRecentSearches() | |
| 126 | } | |
| 127 | } | |
| 128 | } | |
| 96 | 129 | .overlay { |
| 97 | 130 | if viewModel.isLoading, viewModel.repositories.isEmpty { |
| 98 | 131 | SRHTLoadingStateView(message: "Loading repositories…") |
| @@ -110,7 +143,11 @@ struct RepositoryListView: View { | ||
| 110 | 143 | description: Text("You don't have any repositories yet.") |
| 111 | 144 | ) |
| 112 | 145 | } else { |
| 113 | ContentUnavailableView.search | |
| 146 | ContentUnavailableView( | |
| 147 | "No Repository Matches", | |
| 148 | systemImage: "magnifyingglass", | |
| 149 | description: Text("No repositories matched “\(viewModel.searchText)”.") | |
| 150 | ) | |
| 114 | 151 | } |
| 115 | 152 | } |
| 116 | 153 | } |
| @@ -124,9 +161,21 @@ struct RepositoryListView: View { | ||
| 124 | 161 | .task { |
| 125 | 162 | await viewModel.loadRepositories() |
| 126 | 163 | } |
| 164 | .onSubmit(of: .search) { | |
| 165 | let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 166 | guard !query.isEmpty else { return } | |
| 167 | ||
| 168 | searchTask?.cancel() | |
| 169 | immediateSearchTask?.cancel() | |
| 170 | viewModel.recordRecentSearch(query) | |
| 171 | immediateSearchTask = Task { | |
| 172 | await viewModel.loadRepositories(search: query) | |
| 173 | } | |
| 174 | } | |
| 127 | 175 | .onChange(of: viewModel.searchText) { oldValue, newValue in |
| 128 | 176 | // Cancel previous search task |
| 129 | 177 | searchTask?.cancel() |
| 178 | immediateSearchTask?.cancel() | |
| 130 | 179 | |
| 131 | 180 | // Clear results immediately when search text is cleared |
| 132 | 181 | if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { |
Hutch/Views/Repositories/RepositoryListViewModel.swift +34 −2
| @@ -25,9 +25,11 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { | ||
| 25 | 25 | @Observable |
| 26 | 26 | @MainActor |
| 27 | 27 | final class RepositoryListViewModel { |
| 28 | private static let searchHistoryScopeID = "repositories" | |
| 28 | 29 | |
| 29 | 30 | private(set) var repositories: [RepositorySummary] = [] |
| 30 | 31 | private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] |
| 32 | private(set) var recentSearches: [ScopedSearchHistoryEntry] | |
| 31 | 33 | private(set) var isLoading = false |
| 32 | 34 | private(set) var isLoadingMore = false |
| 33 | 35 | private(set) var isRefreshing = false |
| @@ -42,6 +44,7 @@ final class RepositoryListViewModel { | ||
| 42 | 44 | private(set) var hasLoadedSearchIndex = false |
| 43 | 45 | private var searchIndex: [RepositorySummary] = [] |
| 44 | 46 | private let client: SRHTClient |
| 47 | private let defaults: UserDefaults | |
| 45 | 48 | private var buildStatusTask: Task<Void, Never>? |
| 46 | 49 | |
| 47 | 50 | private static let gitCacheKey = "git.repositories" |
| @@ -49,8 +52,13 @@ final class RepositoryListViewModel { | ||
| 49 | 52 | private static let buildsCacheKey = "builds.repository-status" |
| 50 | 53 | private static let minimumRemoteSearchLength = 3 |
| 51 | 54 | |
| 52 | init(client: SRHTClient) { | |
| 55 | init(client: SRHTClient, defaults: UserDefaults = .standard) { | |
| 53 | 56 | self.client = client |
| 57 | self.defaults = defaults | |
| 58 | self.recentSearches = ScopedSearchHistoryStore.load( | |
| 59 | scopeID: Self.searchHistoryScopeID, | |
| 60 | defaults: defaults | |
| 61 | ) | |
| 54 | 62 | } |
| 55 | 63 | |
| 56 | 64 | // MARK: - Queries |
| @@ -302,6 +310,26 @@ final class RepositoryListViewModel { | ||
| 302 | 310 | isSearching = false |
| 303 | 311 | } |
| 304 | 312 | |
| 313 | func recordRecentSearch(_ query: String) { | |
| 314 | ScopedSearchHistoryStore.record( | |
| 315 | query: query, | |
| 316 | scopeID: Self.searchHistoryScopeID, | |
| 317 | defaults: defaults | |
| 318 | ) | |
| 319 | recentSearches = ScopedSearchHistoryStore.load( | |
| 320 | scopeID: Self.searchHistoryScopeID, | |
| 321 | defaults: defaults | |
| 322 | ) | |
| 323 | } | |
| 324 | ||
| 325 | func clearRecentSearches() { | |
| 326 | ScopedSearchHistoryStore.clear( | |
| 327 | scopeID: Self.searchHistoryScopeID, | |
| 328 | defaults: defaults | |
| 329 | ) | |
| 330 | recentSearches = [] | |
| 331 | } | |
| 332 | ||
| 305 | 333 | // MARK: - Private |
| 306 | 334 | |
| 307 | 335 | /// Page shape matching the GraphQL response without generic constraints that |
| @@ -696,9 +724,13 @@ final class RepositoryListViewModel { | ||
| 696 | 724 | } |
| 697 | 725 | |
| 698 | 726 | static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] { |
| 699 | let lowercasedQuery = query.lowercased() | |
| 727 | let lowercasedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 728 | guard !lowercasedQuery.isEmpty else { return repositories } | |
| 729 | ||
| 700 | 730 | return repositories.filter { repo in |
| 701 | 731 | repo.name.lowercased().contains(lowercasedQuery) || |
| 732 | repo.owner.canonicalName.lowercased().contains(lowercasedQuery) || | |
| 733 | repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false || | |
| 702 | 734 | repo.description?.lowercased().contains(lowercasedQuery) ?? false |
| 703 | 735 | } |
| 704 | 736 | } |
Hutch/Views/Search/RecentSearchSuggestions.swift added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct RecentSearchSuggestions: View { | |
| 4 | let title: String | |
| 5 | let entries: [ScopedSearchHistoryEntry] | |
| 6 | let onSelect: (String) -> Void | |
| 7 | let onClear: () -> Void | |
| 8 | ||
| 9 | var body: some View { | |
| 10 | if !entries.isEmpty { | |
| 11 | Section(title) { | |
| 12 | ForEach(entries) { entry in | |
| 13 | Button { | |
| 14 | onSelect(entry.query) | |
| 15 | } label: { | |
| 16 | Label(entry.query, systemImage: "clock.arrow.circlepath") | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | Button("Clear Recent Searches", role: .destructive) { | |
| 21 | onClear() | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
Hutch/Views/Search/ScopedSearchHistoryStore.swift added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | struct ScopedSearchHistoryEntry: Codable, Hashable, Identifiable, Sendable { | |
| 4 | let scopeID: String | |
| 5 | let query: String | |
| 6 | let createdAt: Date | |
| 7 | ||
| 8 | var id: String { | |
| 9 | "\(scopeID):\(query.lowercased())" | |
| 10 | } | |
| 11 | } | |
| 12 | ||
| 13 | enum ScopedSearchHistoryStore { | |
| 14 | private static let maximumEntriesPerScope = 8 | |
| 15 | ||
| 16 | static func load(scopeID: String, defaults: UserDefaults = .standard) -> [ScopedSearchHistoryEntry] { | |
| 17 | loadAll(defaults: defaults)[scopeID] ?? [] | |
| 18 | } | |
| 19 | ||
| 20 | static func record( | |
| 21 | query: String, | |
| 22 | scopeID: String, | |
| 23 | defaults: UserDefaults = .standard, | |
| 24 | now: Date = .now | |
| 25 | ) { | |
| 26 | let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 27 | guard !normalizedQuery.isEmpty else { return } | |
| 28 | ||
| 29 | var allEntries = loadAll(defaults: defaults) | |
| 30 | var scopeEntries = allEntries[scopeID] ?? [] | |
| 31 | scopeEntries.removeAll { | |
| 32 | $0.query.compare(normalizedQuery, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame | |
| 33 | } | |
| 34 | scopeEntries.insert( | |
| 35 | ScopedSearchHistoryEntry(scopeID: scopeID, query: normalizedQuery, createdAt: now), | |
| 36 | at: 0 | |
| 37 | ) | |
| 38 | allEntries[scopeID] = Array(scopeEntries.prefix(maximumEntriesPerScope)) | |
| 39 | save(allEntries, defaults: defaults) | |
| 40 | } | |
| 41 | ||
| 42 | static func clear(scopeID: String, defaults: UserDefaults = .standard) { | |
| 43 | var allEntries = loadAll(defaults: defaults) | |
| 44 | allEntries.removeValue(forKey: scopeID) | |
| 45 | save(allEntries, defaults: defaults) | |
| 46 | } | |
| 47 | ||
| 48 | private static func loadAll(defaults: UserDefaults) -> [String: [ScopedSearchHistoryEntry]] { | |
| 49 | guard let data = defaults.data(forKey: AppStorageKeys.scopedSearchHistory) else { | |
| 50 | return [:] | |
| 51 | } | |
| 52 | ||
| 53 | do { | |
| 54 | return try JSONDecoder().decode([String: [ScopedSearchHistoryEntry]].self, from: data) | |
| 55 | } catch { | |
| 56 | defaults.removeObject(forKey: AppStorageKeys.scopedSearchHistory) | |
| 57 | return [:] | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private static func save(_ entries: [String: [ScopedSearchHistoryEntry]], defaults: UserDefaults) { | |
| 62 | guard let data = try? JSONEncoder().encode(entries) else { return } | |
| 63 | defaults.set(data, forKey: AppStorageKeys.scopedSearchHistory) | |
| 64 | } | |
| 65 | } | |
Hutch/Views/Tickets/TicketListView.swift +23 −2
| @@ -328,8 +328,25 @@ struct TicketListView: View { | ||
| 328 | 328 | .searchable( |
| 329 | 329 | text: $vm.searchText, |
| 330 | 330 | placement: .navigationBarDrawer(displayMode: .always), |
| 331 | prompt: "Search tickets" | |
| 331 | prompt: "Search tickets in \(tracker.name)" | |
| 332 | 332 | ) |
| 333 | .searchSuggestions { | |
| 334 | if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 335 | RecentSearchSuggestions( | |
| 336 | title: "Recent Ticket Searches", | |
| 337 | entries: viewModel.recentSearches | |
| 338 | ) { query in | |
| 339 | vm.searchText = query | |
| 340 | } onClear: { | |
| 341 | viewModel.clearRecentSearches() | |
| 342 | } | |
| 343 | } | |
| 344 | } | |
| 345 | .onSubmit(of: .search) { | |
| 346 | let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 347 | guard !query.isEmpty else { return } | |
| 348 | viewModel.recordRecentSearch(query) | |
| 349 | } | |
| 333 | 350 | .overlay { |
| 334 | 351 | if viewModel.isLoading, viewModel.tickets.isEmpty { |
| 335 | 352 | SRHTLoadingStateView(message: "Loading tickets…") |
| @@ -341,7 +358,11 @@ struct TicketListView: View { | ||
| 341 | 358 | ) |
| 342 | 359 | } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
| 343 | 360 | viewModel.filteredTickets.isEmpty { |
| 344 | ContentUnavailableView.search(text: viewModel.searchText) | |
| 361 | ContentUnavailableView( | |
| 362 | "No Ticket Matches", | |
| 363 | systemImage: "magnifyingglass", | |
| 364 | description: Text("No tickets in \(tracker.name) matched “\(viewModel.searchText)”.") | |
| 365 | ) | |
| 345 | 366 | } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { |
| 346 | 367 | ContentUnavailableView( |
| 347 | 368 | "No Tickets", |
Hutch/Views/Tickets/TicketListViewModel.swift +35 −3
| @@ -74,6 +74,10 @@ enum TicketFilter: String, CaseIterable, Codable, Sendable { | ||
| 74 | 74 | @Observable |
| 75 | 75 | @MainActor |
| 76 | 76 | final class TicketListViewModel { |
| 77 | private static func searchHistoryScopeID(for trackerRid: String) -> String { | |
| 78 | "tickets.\(trackerRid)" | |
| 79 | } | |
| 80 | ||
| 77 | 81 | let ownerUsername: String |
| 78 | 82 | let trackerName: String |
| 79 | 83 | let trackerId: Int |
| @@ -85,6 +89,7 @@ final class TicketListViewModel { | ||
| 85 | 89 | private(set) var isCreatingTicket = false |
| 86 | 90 | private(set) var isPerformingAction = false |
| 87 | 91 | private(set) var trackerLabels: [TicketLabel] = [] |
| 92 | private(set) var recentSearches: [ScopedSearchHistoryEntry] | |
| 88 | 93 | private(set) var savedFilters: [SavedTicketFilter] |
| 89 | 94 | private(set) var isSelectionMode = false |
| 90 | 95 | private(set) var selectedTicketIDs: Set<Int> = [] |
| @@ -123,10 +128,15 @@ final class TicketListViewModel { | ||
| 123 | 128 | self.defaults = defaults |
| 124 | 129 | |
| 125 | 130 | let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults) |
| 131 | let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults) | |
| 126 | 132 | self.filter = restoredState.status |
| 127 | 133 | self.selectedLabelIDs = Set(restoredState.labelIDs) |
| 128 | self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults) | |
| 129 | self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id | |
| 134 | self.savedFilters = savedFilters | |
| 135 | self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id | |
| 136 | self.recentSearches = ScopedSearchHistoryStore.load( | |
| 137 | scopeID: Self.searchHistoryScopeID(for: trackerRid), | |
| 138 | defaults: defaults | |
| 139 | ) | |
| 130 | 140 | } |
| 131 | 141 | |
| 132 | 142 | // MARK: - Query |
| @@ -505,6 +515,26 @@ final class TicketListViewModel { | ||
| 505 | 515 | selectedLabelIDs = [] |
| 506 | 516 | } |
| 507 | 517 | |
| 518 | func recordRecentSearch(_ query: String) { | |
| 519 | ScopedSearchHistoryStore.record( | |
| 520 | query: query, | |
| 521 | scopeID: Self.searchHistoryScopeID(for: trackerRid), | |
| 522 | defaults: defaults | |
| 523 | ) | |
| 524 | recentSearches = ScopedSearchHistoryStore.load( | |
| 525 | scopeID: Self.searchHistoryScopeID(for: trackerRid), | |
| 526 | defaults: defaults | |
| 527 | ) | |
| 528 | } | |
| 529 | ||
| 530 | func clearRecentSearches() { | |
| 531 | ScopedSearchHistoryStore.clear( | |
| 532 | scopeID: Self.searchHistoryScopeID(for: trackerRid), | |
| 533 | defaults: defaults | |
| 534 | ) | |
| 535 | recentSearches = [] | |
| 536 | } | |
| 537 | ||
| 508 | 538 | func applySavedFilter(_ savedFilter: SavedTicketFilter) { |
| 509 | 539 | filter = savedFilter.state.status |
| 510 | 540 | selectedLabelIDs = Set(savedFilter.state.labelIDs) |
| @@ -938,7 +968,9 @@ final class TicketListViewModel { | ||
| 938 | 968 | String($0.id).contains(q) || |
| 939 | 969 | $0.title.lowercased().contains(q) || |
| 940 | 970 | $0.submitter.canonicalName.lowercased().contains(q) || |
| 941 | $0.labels.contains { $0.name.lowercased().contains(q) } | |
| 971 | $0.labels.contains { $0.name.lowercased().contains(q) } || | |
| 972 | $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } || | |
| 973 | $0.status.displayName.lowercased().contains(q) | |
| 942 | 974 | } |
| 943 | 975 | } |
| 944 | 976 | } |
HutchTests/BuildListViewModelTests.swift +13 −8
| @@ -28,6 +28,18 @@ struct BuildListViewModelTests { | ||
| 28 | 28 | #expect(filtered.map(\.id) == [42]) |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | @Test | |
| 32 | func filteredJobsMatchesByStatus() { | |
| 33 | let jobs = [ | |
| 34 | makeJob(id: 1, status: .running, tags: []), | |
| 35 | makeJob(id: 2, status: .success, tags: []) | |
| 36 | ] | |
| 37 | ||
| 38 | let filtered = filterJobs(jobs, query: "running") | |
| 39 | ||
| 40 | #expect(filtered.map(\.id) == [1]) | |
| 41 | } | |
| 42 | ||
| 31 | 43 | @Test |
| 32 | 44 | func buildFilterPrioritizesActionableStates() { |
| 33 | 45 | let jobs = [ |
| @@ -45,14 +57,7 @@ struct BuildListViewModelTests { | ||
| 45 | 57 | } |
| 46 | 58 | |
| 47 | 59 | private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { |
| 48 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 49 | guard !q.isEmpty else { return jobs } | |
| 50 | return jobs.filter { | |
| 51 | String($0.id).contains(q) || | |
| 52 | $0.tags.contains { $0.lowercased().contains(q) } || | |
| 53 | ($0.note?.lowercased().contains(q) == true) || | |
| 54 | ($0.image?.lowercased().contains(q) == true) | |
| 55 | } | |
| 60 | BuildListViewModel.searchJobs(jobs, matching: query) | |
| 56 | 61 | } |
| 57 | 62 | |
| 58 | 63 | private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { |
HutchTests/RepositoryListViewModelTests.swift +20 −3
| @@ -28,6 +28,21 @@ struct RepositoryListViewModelTests { | ||
| 28 | 28 | #expect(descriptionMatches.map(\.id) == [2]) |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | @Test | |
| 32 | @MainActor | |
| 33 | func filterRepositoriesMatchesOwnerAndDefaultBranchLocally() { | |
| 34 | let repositories = [ | |
| 35 | makeRepository(id: 1, service: .git, name: "Hutch", description: nil, owner: "~alice", branch: "main"), | |
| 36 | makeRepository(id: 2, service: .hg, name: "Mail", description: nil, owner: "~bob", branch: "stable") | |
| 37 | ] | |
| 38 | ||
| 39 | let ownerMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "~bob") | |
| 40 | let branchMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "main") | |
| 41 | ||
| 42 | #expect(ownerMatches.map(\.id) == [2]) | |
| 43 | #expect(branchMatches.map(\.id) == [1]) | |
| 44 | } | |
| 45 | ||
| 31 | 46 | @Test |
| 32 | 47 | func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() { |
| 33 | 48 | let manifest = """ |
| @@ -59,7 +74,9 @@ struct RepositoryListViewModelTests { | ||
| 59 | 74 | id: Int, |
| 60 | 75 | service: SRHTService, |
| 61 | 76 | name: String, |
| 62 | description: String? | |
| 77 | description: String?, | |
| 78 | owner: String = "~owner", | |
| 79 | branch: String = "main" | |
| 63 | 80 | ) -> RepositorySummary { |
| 64 | 81 | RepositorySummary( |
| 65 | 82 | id: id, |
| @@ -69,8 +86,8 @@ struct RepositoryListViewModelTests { | ||
| 69 | 86 | description: description, |
| 70 | 87 | visibility: .public, |
| 71 | 88 | updated: Date(timeIntervalSince1970: TimeInterval(id)), |
| 72 | owner: Entity(canonicalName: "~owner"), | |
| 73 | head: Reference(name: "main", target: nil) | |
| 89 | owner: Entity(canonicalName: owner), | |
| 90 | head: Reference(name: branch, target: nil) | |
| 74 | 91 | ) |
| 75 | 92 | } |
| 76 | 93 | } |
HutchTests/ScopedSearchHistoryStoreTests.swift added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct ScopedSearchHistoryStoreTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | func recordsMostRecentUniqueSearchPerScopeFirst() { | |
| 9 | let suiteName = "ScopedSearchHistoryStoreTests-\(UUID().uuidString)" | |
| 10 | let defaults = UserDefaults(suiteName: suiteName)! | |
| 11 | defer { defaults.removePersistentDomain(forName: suiteName) } | |
| 12 | ||
| 13 | ScopedSearchHistoryStore.record( | |
| 14 | query: "hutch", | |
| 15 | scopeID: "repositories", | |
| 16 | defaults: defaults, | |
| 17 | now: Date(timeIntervalSince1970: 100) | |
| 18 | ) | |
| 19 | ScopedSearchHistoryStore.record( | |
| 20 | query: "running", | |
| 21 | scopeID: "builds", | |
| 22 | defaults: defaults, | |
| 23 | now: Date(timeIntervalSince1970: 200) | |
| 24 | ) | |
| 25 | ScopedSearchHistoryStore.record( | |
| 26 | query: "Hutch", | |
| 27 | scopeID: "repositories", | |
| 28 | defaults: defaults, | |
| 29 | now: Date(timeIntervalSince1970: 300) | |
| 30 | ) | |
| 31 | ||
| 32 | let repositoryHistory = ScopedSearchHistoryStore.load(scopeID: "repositories", defaults: defaults) | |
| 33 | let buildHistory = ScopedSearchHistoryStore.load(scopeID: "builds", defaults: defaults) | |
| 34 | ||
| 35 | #expect(repositoryHistory.map(\.query) == ["Hutch"]) | |
| 36 | #expect(repositoryHistory.first?.createdAt == Date(timeIntervalSince1970: 300)) | |
| 37 | #expect(buildHistory.map(\.query) == ["running"]) | |
| 38 | } | |
| 39 | ||
| 40 | @Test | |
| 41 | func clearsOnlyRequestedScope() { | |
| 42 | let suiteName = "ScopedSearchHistoryStoreTests-\(UUID().uuidString)" | |
| 43 | let defaults = UserDefaults(suiteName: suiteName)! | |
| 44 | defer { defaults.removePersistentDomain(forName: suiteName) } | |
| 45 | ||
| 46 | ScopedSearchHistoryStore.record(query: "repo", scopeID: "repositories", defaults: defaults) | |
| 47 | ScopedSearchHistoryStore.record(query: "ticket", scopeID: "tickets.rid", defaults: defaults) | |
| 48 | ||
| 49 | ScopedSearchHistoryStore.clear(scopeID: "repositories", defaults: defaults) | |
| 50 | ||
| 51 | #expect(ScopedSearchHistoryStore.load(scopeID: "repositories", defaults: defaults).isEmpty) | |
| 52 | #expect(ScopedSearchHistoryStore.load(scopeID: "tickets.rid", defaults: defaults).map(\.query) == ["ticket"]) | |
| 53 | } | |
| 54 | } | |
HutchTests/TicketListViewModelTests.swift +39 −2
| @@ -65,6 +65,42 @@ struct TicketListViewModelTests { | ||
| 65 | 65 | #expect(labelMatches.map(\.id) == [1]) |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | @Test | |
| 69 | func filteredTicketsMatchesAssigneeAndStatus() { | |
| 70 | let tickets = [ | |
| 71 | makeTicket( | |
| 72 | id: 1, | |
| 73 | title: "Crash on launch", | |
| 74 | status: .inProgress, | |
| 75 | submitter: "~owner", | |
| 76 | labels: [], | |
| 77 | assignees: [Entity(canonicalName: "~alice")] | |
| 78 | ), | |
| 79 | makeTicket( | |
| 80 | id: 2, | |
| 81 | title: "Settings polish", | |
| 82 | status: .resolved, | |
| 83 | submitter: "~owner", | |
| 84 | labels: [], | |
| 85 | assignees: [] | |
| 86 | ) | |
| 87 | ] | |
| 88 | ||
| 89 | let assigneeMatches = filterTickets( | |
| 90 | tickets, | |
| 91 | state: TicketListFilterState(status: .all), | |
| 92 | query: "~alice" | |
| 93 | ) | |
| 94 | let statusMatches = filterTickets( | |
| 95 | tickets, | |
| 96 | state: TicketListFilterState(status: .all), | |
| 97 | query: "resolved" | |
| 98 | ) | |
| 99 | ||
| 100 | #expect(assigneeMatches.map(\.id) == [1]) | |
| 101 | #expect(statusMatches.map(\.id) == [2]) | |
| 102 | } | |
| 103 | ||
| 68 | 104 | @Test |
| 69 | 105 | func filteredTicketsMatchesAnySelectedLabel() { |
| 70 | 106 | let tickets = [ |
| @@ -153,7 +189,8 @@ struct TicketListViewModelTests { | ||
| 153 | 189 | title: String, |
| 154 | 190 | status: TicketStatus, |
| 155 | 191 | submitter: String, |
| 156 | labels: [TicketLabel] | |
| 192 | labels: [TicketLabel], | |
| 193 | assignees: [Entity] = [] | |
| 157 | 194 | ) -> TicketSummary { |
| 158 | 195 | TicketSummary( |
| 159 | 196 | id: id, |
| @@ -163,7 +200,7 @@ struct TicketListViewModelTests { | ||
| 163 | 200 | created: Date(), |
| 164 | 201 | submitter: Entity(canonicalName: submitter), |
| 165 | 202 | labels: labels, |
| 166 | assignees: [] | |
| 203 | assignees: assignees | |
| 167 | 204 | ) |
| 168 | 205 | } |
| 169 | 206 | |