Commit d18aa66063

d18aa6606366959ff5751f5e47bc00395acd562f

parent: 792c331c90

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 03:52 UTC

feat: add search and recent queries

Implements: https://todo.sr.ht/~ccleberg/hutch/47
Implements: https://todo.sr.ht/~ccleberg/hutch/48

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 46;
518 CURRENT_PROJECT_VERSION = 49;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.19.3;
535 MARKETING_VERSION = 2.20.0;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 46;
555 CURRENT_PROJECT_VERSION = 49;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.19.3;
572 MARKETING_VERSION = 2.20.0;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 46;
635 CURRENT_PROJECT_VERSION = 49;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.19.3;
645 MARKETING_VERSION = 2.20.0;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 46;
664 CURRENT_PROJECT_VERSION = 49;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.19.3;
674 MARKETING_VERSION = 2.20.0;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -5,6 +5,7 @@ enum AppStorageKeys {
55 static let activeAccountID = "activeAccountID"
66 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
77 static let lookupHistory = "lookupHistory"
8 static let scopedSearchHistory = "scopedSearchHistory"
89 static let hutchStatsBaseURL = "hutchStatsBaseURL"
910 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
1011 static let systemStatusIncidentCache = "systemStatusIncidentCache"
Hutch/Views/Builds/BuildListView.swift +25 −3
@@ -167,8 +167,25 @@ struct BuildListView: View {
167167 .searchable(
168168 text: $vm.searchText,
169169 placement: .navigationBarDrawer(displayMode: .always),
170 prompt: "Search builds"
170 prompt: "Search builds by job ID, tag, note, or status"
171171 )
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 }
172189 .overlay {
173190 if viewModel.isLoading, viewModel.jobs.isEmpty {
174191 SRHTLoadingStateView(message: "Loading builds…")
@@ -178,8 +195,13 @@ struct BuildListView: View {
178195 message: error,
179196 retryAction: { await viewModel.loadJobs() }
180197 )
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 )
183205 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
184206 ContentUnavailableView(
185207 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +43 −9
@@ -44,8 +44,10 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
4444@Observable
4545@MainActor
4646final class BuildListViewModel {
47 private static let searchHistoryScopeID = "builds"
4748
4849 private(set) var jobs: [JobSummary] = []
50 private(set) var recentSearches: [ScopedSearchHistoryEntry]
4951 private(set) var isLoading = false
5052 private(set) var isLoadingMore = false
5153 private(set) var isRefreshing = false
@@ -60,13 +62,19 @@ final class BuildListViewModel {
6062 private var cursor: String?
6163 private var hasMore = true
6264 private let client: SRHTClient
65 private let defaults: UserDefaults
6366 private var refreshTask: Task<Void, Never>?
6467 private var isAutoRefreshing = false
6568
6669 private static let cacheKey = "builds.jobs"
6770
68 init(client: SRHTClient) {
71 init(client: SRHTClient, defaults: UserDefaults = .standard) {
6972 self.client = client
73 self.defaults = defaults
74 self.recentSearches = ScopedSearchHistoryStore.load(
75 scopeID: Self.searchHistoryScopeID,
76 defaults: defaults
77 )
7078 }
7179
7280 /// Unique tags across all loaded jobs, sorted alphabetically.
@@ -82,14 +90,7 @@ final class BuildListViewModel {
8290 result = result.filter { $0.tags.contains(repoFilter) }
8391 }
8492
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)
9394 }
9495
9596 // MARK: - Auto-Refresh
@@ -291,6 +292,26 @@ final class BuildListViewModel {
291292 }
292293 }
293294
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
294315 // MARK: - Private
295316
296317 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
@@ -363,4 +384,17 @@ final class BuildListViewModel {
363384 }
364385 }
365386 }
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 }
366400}
Hutch/Views/Repositories/RepositoryListView.swift +51 −2
@@ -4,6 +4,7 @@ struct RepositoryListView: View {
44 @Environment(AppState.self) private var appState
55 @State private var viewModel: RepositoryListViewModel?
66 @State private var searchTask: Task<Void, Never>?
7 @State private var immediateSearchTask: Task<Void, Never>?
78 @State private var showCreateRepositorySheet = false
89 @State private var createdRepository: RepositorySummary?
910
@@ -68,6 +69,17 @@ struct RepositoryListView: View {
6869 @Bindable var vm = viewModel
6970
7071 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
7183 ForEach(viewModel.repositories) { repo in
7284 NavigationLink(value: repo) {
7385 RepositoryRowView(
@@ -92,7 +104,28 @@ struct RepositoryListView: View {
92104 }
93105 .themedList()
94106 .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 }
96129 .overlay {
97130 if viewModel.isLoading, viewModel.repositories.isEmpty {
98131 SRHTLoadingStateView(message: "Loading repositories…")
@@ -110,7 +143,11 @@ struct RepositoryListView: View {
110143 description: Text("You don't have any repositories yet.")
111144 )
112145 } else {
113 ContentUnavailableView.search
146 ContentUnavailableView(
147 "No Repository Matches",
148 systemImage: "magnifyingglass",
149 description: Text("No repositories matched “\(viewModel.searchText)”.")
150 )
114151 }
115152 }
116153 }
@@ -124,9 +161,21 @@ struct RepositoryListView: View {
124161 .task {
125162 await viewModel.loadRepositories()
126163 }
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 }
127175 .onChange(of: viewModel.searchText) { oldValue, newValue in
128176 // Cancel previous search task
129177 searchTask?.cancel()
178 immediateSearchTask?.cancel()
130179
131180 // Clear results immediately when search text is cleared
132181 if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Hutch/Views/Repositories/RepositoryListViewModel.swift +34 −2
@@ -25,9 +25,11 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
2525@Observable
2626@MainActor
2727final class RepositoryListViewModel {
28 private static let searchHistoryScopeID = "repositories"
2829
2930 private(set) var repositories: [RepositorySummary] = []
3031 private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
32 private(set) var recentSearches: [ScopedSearchHistoryEntry]
3133 private(set) var isLoading = false
3234 private(set) var isLoadingMore = false
3335 private(set) var isRefreshing = false
@@ -42,6 +44,7 @@ final class RepositoryListViewModel {
4244 private(set) var hasLoadedSearchIndex = false
4345 private var searchIndex: [RepositorySummary] = []
4446 private let client: SRHTClient
47 private let defaults: UserDefaults
4548 private var buildStatusTask: Task<Void, Never>?
4649
4750 private static let gitCacheKey = "git.repositories"
@@ -49,8 +52,13 @@ final class RepositoryListViewModel {
4952 private static let buildsCacheKey = "builds.repository-status"
5053 private static let minimumRemoteSearchLength = 3
5154
52 init(client: SRHTClient) {
55 init(client: SRHTClient, defaults: UserDefaults = .standard) {
5356 self.client = client
57 self.defaults = defaults
58 self.recentSearches = ScopedSearchHistoryStore.load(
59 scopeID: Self.searchHistoryScopeID,
60 defaults: defaults
61 )
5462 }
5563
5664 // MARK: - Queries
@@ -302,6 +310,26 @@ final class RepositoryListViewModel {
302310 isSearching = false
303311 }
304312
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
305333 // MARK: - Private
306334
307335 /// Page shape matching the GraphQL response without generic constraints that
@@ -696,9 +724,13 @@ final class RepositoryListViewModel {
696724 }
697725
698726 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
700730 return repositories.filter { repo in
701731 repo.name.lowercased().contains(lowercasedQuery) ||
732 repo.owner.canonicalName.lowercased().contains(lowercasedQuery) ||
733 repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false ||
702734 repo.description?.lowercased().contains(lowercasedQuery) ?? false
703735 }
704736 }
Hutch/Views/Search/RecentSearchSuggestions.swift added +26
@@ -0,0 +1,26 @@
1import SwiftUI
2
3struct 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 @@
1import Foundation
2
3struct 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
13enum 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 {
328328 .searchable(
329329 text: $vm.searchText,
330330 placement: .navigationBarDrawer(displayMode: .always),
331 prompt: "Search tickets"
331 prompt: "Search tickets in \(tracker.name)"
332332 )
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 }
333350 .overlay {
334351 if viewModel.isLoading, viewModel.tickets.isEmpty {
335352 SRHTLoadingStateView(message: "Loading tickets…")
@@ -341,7 +358,11 @@ struct TicketListView: View {
341358 )
342359 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
343360 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 )
345366 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
346367 ContentUnavailableView(
347368 "No Tickets",
Hutch/Views/Tickets/TicketListViewModel.swift +35 −3
@@ -74,6 +74,10 @@ enum TicketFilter: String, CaseIterable, Codable, Sendable {
7474@Observable
7575@MainActor
7676final class TicketListViewModel {
77 private static func searchHistoryScopeID(for trackerRid: String) -> String {
78 "tickets.\(trackerRid)"
79 }
80
7781 let ownerUsername: String
7882 let trackerName: String
7983 let trackerId: Int
@@ -85,6 +89,7 @@ final class TicketListViewModel {
8589 private(set) var isCreatingTicket = false
8690 private(set) var isPerformingAction = false
8791 private(set) var trackerLabels: [TicketLabel] = []
92 private(set) var recentSearches: [ScopedSearchHistoryEntry]
8893 private(set) var savedFilters: [SavedTicketFilter]
8994 private(set) var isSelectionMode = false
9095 private(set) var selectedTicketIDs: Set<Int> = []
@@ -123,10 +128,15 @@ final class TicketListViewModel {
123128 self.defaults = defaults
124129
125130 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
131 let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
126132 self.filter = restoredState.status
127133 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 )
130140 }
131141
132142 // MARK: - Query
@@ -505,6 +515,26 @@ final class TicketListViewModel {
505515 selectedLabelIDs = []
506516 }
507517
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
508538 func applySavedFilter(_ savedFilter: SavedTicketFilter) {
509539 filter = savedFilter.state.status
510540 selectedLabelIDs = Set(savedFilter.state.labelIDs)
@@ -938,7 +968,9 @@ final class TicketListViewModel {
938968 String($0.id).contains(q) ||
939969 $0.title.lowercased().contains(q) ||
940970 $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)
942974 }
943975 }
944976}
HutchTests/BuildListViewModelTests.swift +13 −8
@@ -28,6 +28,18 @@ struct BuildListViewModelTests {
2828 #expect(filtered.map(\.id) == [42])
2929 }
3030
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
3143 @Test
3244 func buildFilterPrioritizesActionableStates() {
3345 let jobs = [
@@ -45,14 +57,7 @@ struct BuildListViewModelTests {
4557 }
4658
4759 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)
5661 }
5762
5863 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
HutchTests/RepositoryListViewModelTests.swift +20 −3
@@ -28,6 +28,21 @@ struct RepositoryListViewModelTests {
2828 #expect(descriptionMatches.map(\.id) == [2])
2929 }
3030
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
3146 @Test
3247 func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() {
3348 let manifest = """
@@ -59,7 +74,9 @@ struct RepositoryListViewModelTests {
5974 id: Int,
6075 service: SRHTService,
6176 name: String,
62 description: String?
77 description: String?,
78 owner: String = "~owner",
79 branch: String = "main"
6380 ) -> RepositorySummary {
6481 RepositorySummary(
6582 id: id,
@@ -69,8 +86,8 @@ struct RepositoryListViewModelTests {
6986 description: description,
7087 visibility: .public,
7188 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)
7491 )
7592 }
7693}
HutchTests/ScopedSearchHistoryStoreTests.swift added +54
@@ -0,0 +1,54 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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 {
6565 #expect(labelMatches.map(\.id) == [1])
6666 }
6767
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
68104 @Test
69105 func filteredTicketsMatchesAnySelectedLabel() {
70106 let tickets = [
@@ -153,7 +189,8 @@ struct TicketListViewModelTests {
153189 title: String,
154190 status: TicketStatus,
155191 submitter: String,
156 labels: [TicketLabel]
192 labels: [TicketLabel],
193 assignees: [Entity] = []
157194 ) -> TicketSummary {
158195 TicketSummary(
159196 id: id,
@@ -163,7 +200,7 @@ struct TicketListViewModelTests {
163200 created: Date(),
164201 submitter: Entity(canonicalName: submitter),
165202 labels: labels,
166 assignees: []
203 assignees: assignees
167204 )
168205 }
169206