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 @@
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 = 46; 518 CURRENT_PROJECT_VERSION = 49;
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 = 2.19.3; 535 MARKETING_VERSION = 2.20.0;
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 = 46; 555 CURRENT_PROJECT_VERSION = 49;
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 = 2.19.3; 572 MARKETING_VERSION = 2.20.0;
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 = 46; 635 CURRENT_PROJECT_VERSION = 49;
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 = 2.19.3; 645 MARKETING_VERSION = 2.20.0;
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 = 46; 664 CURRENT_PROJECT_VERSION = 49;
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 = 2.19.3; 674 MARKETING_VERSION = 2.20.0;
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/App/AppStorageKeys.swift +1
@@ -5,6 +5,7 @@ enum AppStorageKeys {
5 static let activeAccountID = "activeAccountID" 5 static let activeAccountID = "activeAccountID"
6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines" 6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
7 static let lookupHistory = "lookupHistory" 7 static let lookupHistory = "lookupHistory"
8 static let scopedSearchHistory = "scopedSearchHistory"
8 static let hutchStatsBaseURL = "hutchStatsBaseURL" 9 static let hutchStatsBaseURL = "hutchStatsBaseURL"
9 static let systemStatusSnapshotCache = "systemStatusSnapshotCache" 10 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
10 static let systemStatusIncidentCache = "systemStatusIncidentCache" 11 static let systemStatusIncidentCache = "systemStatusIncidentCache"
Hutch/Views/Builds/BuildListView.swift +25 −3
@@ -167,8 +167,25 @@ struct BuildListView: View {
167 .searchable( 167 .searchable(
168 text: $vm.searchText, 168 text: $vm.searchText,
169 placement: .navigationBarDrawer(displayMode: .always), 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 .overlay { 189 .overlay {
173 if viewModel.isLoading, viewModel.jobs.isEmpty { 190 if viewModel.isLoading, viewModel.jobs.isEmpty {
174 SRHTLoadingStateView(message: "Loading builds…") 191 SRHTLoadingStateView(message: "Loading builds…")
@@ -178,8 +195,13 @@ struct BuildListView: View {
178 message: error, 195 message: error,
179 retryAction: { await viewModel.loadJobs() } 196 retryAction: { await viewModel.loadJobs() }
180 ) 197 )
181 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { 198 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
182 ContentUnavailableView.search(text: viewModel.searchText) 199 viewModel.filteredJobs.isEmpty {
200 ContentUnavailableView(
201 "No Build Matches",
202 systemImage: "magnifyingglass",
203 description: Text("No builds matched “\(viewModel.searchText)”.")
204 )
183 } else if viewModel.jobs.isEmpty, viewModel.error == nil { 205 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
184 ContentUnavailableView( 206 ContentUnavailableView(
185 "No Builds", 207 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +43 −9
@@ -44,8 +44,10 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
44@Observable 44@Observable
45@MainActor 45@MainActor
46final class BuildListViewModel { 46final class BuildListViewModel {
47 private static let searchHistoryScopeID = "builds"
47 48
48 private(set) var jobs: [JobSummary] = [] 49 private(set) var jobs: [JobSummary] = []
50 private(set) var recentSearches: [ScopedSearchHistoryEntry]
49 private(set) var isLoading = false 51 private(set) var isLoading = false
50 private(set) var isLoadingMore = false 52 private(set) var isLoadingMore = false
51 private(set) var isRefreshing = false 53 private(set) var isRefreshing = false
@@ -60,13 +62,19 @@ final class BuildListViewModel {
60 private var cursor: String? 62 private var cursor: String?
61 private var hasMore = true 63 private var hasMore = true
62 private let client: SRHTClient 64 private let client: SRHTClient
65 private let defaults: UserDefaults
63 private var refreshTask: Task<Void, Never>? 66 private var refreshTask: Task<Void, Never>?
64 private var isAutoRefreshing = false 67 private var isAutoRefreshing = false
65 68
66 private static let cacheKey = "builds.jobs" 69 private static let cacheKey = "builds.jobs"
67 70
68 init(client: SRHTClient) { 71 init(client: SRHTClient, defaults: UserDefaults = .standard) {
69 self.client = client 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 /// Unique tags across all loaded jobs, sorted alphabetically. 80 /// Unique tags across all loaded jobs, sorted alphabetically.
@@ -82,14 +90,7 @@ final class BuildListViewModel {
82 result = result.filter { $0.tags.contains(repoFilter) } 90 result = result.filter { $0.tags.contains(repoFilter) }
83 } 91 }
84 92
85 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 93 return Self.searchJobs(result, matching: searchText)
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 } 94 }
94 95
95 // MARK: - Auto-Refresh 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 // MARK: - Private 315 // MARK: - Private
295 316
296 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { 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 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @State private var viewModel: RepositoryListViewModel? 5 @State private var viewModel: RepositoryListViewModel?
6 @State private var searchTask: Task<Void, Never>? 6 @State private var searchTask: Task<Void, Never>?
7 @State private var immediateSearchTask: Task<Void, Never>?
7 @State private var showCreateRepositorySheet = false 8 @State private var showCreateRepositorySheet = false
8 @State private var createdRepository: RepositorySummary? 9 @State private var createdRepository: RepositorySummary?
9 10
@@ -68,6 +69,17 @@ struct RepositoryListView: View {
68 @Bindable var vm = viewModel 69 @Bindable var vm = viewModel
69 70
70 List { 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 ForEach(viewModel.repositories) { repo in 83 ForEach(viewModel.repositories) { repo in
72 NavigationLink(value: repo) { 84 NavigationLink(value: repo) {
73 RepositoryRowView( 85 RepositoryRowView(
@@ -92,7 +104,28 @@ struct RepositoryListView: View {
92 } 104 }
93 .themedList() 105 .themedList()
94 .listStyle(.plain) 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 .overlay { 129 .overlay {
97 if viewModel.isLoading, viewModel.repositories.isEmpty { 130 if viewModel.isLoading, viewModel.repositories.isEmpty {
98 SRHTLoadingStateView(message: "Loading repositories…") 131 SRHTLoadingStateView(message: "Loading repositories…")
@@ -110,7 +143,11 @@ struct RepositoryListView: View {
110 description: Text("You don't have any repositories yet.") 143 description: Text("You don't have any repositories yet.")
111 ) 144 )
112 } else { 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 .task { 161 .task {
125 await viewModel.loadRepositories() 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 .onChange(of: viewModel.searchText) { oldValue, newValue in 175 .onChange(of: viewModel.searchText) { oldValue, newValue in
128 // Cancel previous search task 176 // Cancel previous search task
129 searchTask?.cancel() 177 searchTask?.cancel()
178 immediateSearchTask?.cancel()
130 179
131 // Clear results immediately when search text is cleared 180 // Clear results immediately when search text is cleared
132 if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 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@Observable 25@Observable
26@MainActor 26@MainActor
27final class RepositoryListViewModel { 27final class RepositoryListViewModel {
28 private static let searchHistoryScopeID = "repositories"
28 29
29 private(set) var repositories: [RepositorySummary] = [] 30 private(set) var repositories: [RepositorySummary] = []
30 private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] 31 private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
32 private(set) var recentSearches: [ScopedSearchHistoryEntry]
31 private(set) var isLoading = false 33 private(set) var isLoading = false
32 private(set) var isLoadingMore = false 34 private(set) var isLoadingMore = false
33 private(set) var isRefreshing = false 35 private(set) var isRefreshing = false
@@ -42,6 +44,7 @@ final class RepositoryListViewModel {
42 private(set) var hasLoadedSearchIndex = false 44 private(set) var hasLoadedSearchIndex = false
43 private var searchIndex: [RepositorySummary] = [] 45 private var searchIndex: [RepositorySummary] = []
44 private let client: SRHTClient 46 private let client: SRHTClient
47 private let defaults: UserDefaults
45 private var buildStatusTask: Task<Void, Never>? 48 private var buildStatusTask: Task<Void, Never>?
46 49
47 private static let gitCacheKey = "git.repositories" 50 private static let gitCacheKey = "git.repositories"
@@ -49,8 +52,13 @@ final class RepositoryListViewModel {
49 private static let buildsCacheKey = "builds.repository-status" 52 private static let buildsCacheKey = "builds.repository-status"
50 private static let minimumRemoteSearchLength = 3 53 private static let minimumRemoteSearchLength = 3
51 54
52 init(client: SRHTClient) { 55 init(client: SRHTClient, defaults: UserDefaults = .standard) {
53 self.client = client 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 // MARK: - Queries 64 // MARK: - Queries
@@ -302,6 +310,26 @@ final class RepositoryListViewModel {
302 isSearching = false 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 // MARK: - Private 333 // MARK: - Private
306 334
307 /// Page shape matching the GraphQL response without generic constraints that 335 /// Page shape matching the GraphQL response without generic constraints that
@@ -696,9 +724,13 @@ final class RepositoryListViewModel {
696 } 724 }
697 725
698 static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] { 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 return repositories.filter { repo in 730 return repositories.filter { repo in
701 repo.name.lowercased().contains(lowercasedQuery) || 731 repo.name.lowercased().contains(lowercasedQuery) ||
732 repo.owner.canonicalName.lowercased().contains(lowercasedQuery) ||
733 repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false ||
702 repo.description?.lowercased().contains(lowercasedQuery) ?? false 734 repo.description?.lowercased().contains(lowercasedQuery) ?? false
703 } 735 }
704 } 736 }
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 {
328 .searchable( 328 .searchable(
329 text: $vm.searchText, 329 text: $vm.searchText,
330 placement: .navigationBarDrawer(displayMode: .always), 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 .overlay { 350 .overlay {
334 if viewModel.isLoading, viewModel.tickets.isEmpty { 351 if viewModel.isLoading, viewModel.tickets.isEmpty {
335 SRHTLoadingStateView(message: "Loading tickets…") 352 SRHTLoadingStateView(message: "Loading tickets…")
@@ -341,7 +358,11 @@ struct TicketListView: View {
341 ) 358 )
342 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, 359 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
343 viewModel.filteredTickets.isEmpty { 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 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { 366 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
346 ContentUnavailableView( 367 ContentUnavailableView(
347 "No Tickets", 368 "No Tickets",
Hutch/Views/Tickets/TicketListViewModel.swift +35 −3
@@ -74,6 +74,10 @@ enum TicketFilter: String, CaseIterable, Codable, Sendable {
74@Observable 74@Observable
75@MainActor 75@MainActor
76final class TicketListViewModel { 76final class TicketListViewModel {
77 private static func searchHistoryScopeID(for trackerRid: String) -> String {
78 "tickets.\(trackerRid)"
79 }
80
77 let ownerUsername: String 81 let ownerUsername: String
78 let trackerName: String 82 let trackerName: String
79 let trackerId: Int 83 let trackerId: Int
@@ -85,6 +89,7 @@ final class TicketListViewModel {
85 private(set) var isCreatingTicket = false 89 private(set) var isCreatingTicket = false
86 private(set) var isPerformingAction = false 90 private(set) var isPerformingAction = false
87 private(set) var trackerLabels: [TicketLabel] = [] 91 private(set) var trackerLabels: [TicketLabel] = []
92 private(set) var recentSearches: [ScopedSearchHistoryEntry]
88 private(set) var savedFilters: [SavedTicketFilter] 93 private(set) var savedFilters: [SavedTicketFilter]
89 private(set) var isSelectionMode = false 94 private(set) var isSelectionMode = false
90 private(set) var selectedTicketIDs: Set<Int> = [] 95 private(set) var selectedTicketIDs: Set<Int> = []
@@ -123,10 +128,15 @@ final class TicketListViewModel {
123 self.defaults = defaults 128 self.defaults = defaults
124 129
125 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults) 130 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
131 let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
126 self.filter = restoredState.status 132 self.filter = restoredState.status
127 self.selectedLabelIDs = Set(restoredState.labelIDs) 133 self.selectedLabelIDs = Set(restoredState.labelIDs)
128 self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults) 134 self.savedFilters = savedFilters
129 self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id 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 // MARK: - Query 142 // MARK: - Query
@@ -505,6 +515,26 @@ final class TicketListViewModel {
505 selectedLabelIDs = [] 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 func applySavedFilter(_ savedFilter: SavedTicketFilter) { 538 func applySavedFilter(_ savedFilter: SavedTicketFilter) {
509 filter = savedFilter.state.status 539 filter = savedFilter.state.status
510 selectedLabelIDs = Set(savedFilter.state.labelIDs) 540 selectedLabelIDs = Set(savedFilter.state.labelIDs)
@@ -938,7 +968,9 @@ final class TicketListViewModel {
938 String($0.id).contains(q) || 968 String($0.id).contains(q) ||
939 $0.title.lowercased().contains(q) || 969 $0.title.lowercased().contains(q) ||
940 $0.submitter.canonicalName.lowercased().contains(q) || 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 #expect(filtered.map(\.id) == [42]) 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 @Test 43 @Test
32 func buildFilterPrioritizesActionableStates() { 44 func buildFilterPrioritizesActionableStates() {
33 let jobs = [ 45 let jobs = [
@@ -45,14 +57,7 @@ struct BuildListViewModelTests {
45 } 57 }
46 58
47 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { 59 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
48 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 60 BuildListViewModel.searchJobs(jobs, matching: query)
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 }
56 } 61 }
57 62
58 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { 63 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
HutchTests/RepositoryListViewModelTests.swift +20 −3
@@ -28,6 +28,21 @@ struct RepositoryListViewModelTests {
28 #expect(descriptionMatches.map(\.id) == [2]) 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 @Test 46 @Test
32 func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() { 47 func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() {
33 let manifest = """ 48 let manifest = """
@@ -59,7 +74,9 @@ struct RepositoryListViewModelTests {
59 id: Int, 74 id: Int,
60 service: SRHTService, 75 service: SRHTService,
61 name: String, 76 name: String,
62 description: String? 77 description: String?,
78 owner: String = "~owner",
79 branch: String = "main"
63 ) -> RepositorySummary { 80 ) -> RepositorySummary {
64 RepositorySummary( 81 RepositorySummary(
65 id: id, 82 id: id,
@@ -69,8 +86,8 @@ struct RepositoryListViewModelTests {
69 description: description, 86 description: description,
70 visibility: .public, 87 visibility: .public,
71 updated: Date(timeIntervalSince1970: TimeInterval(id)), 88 updated: Date(timeIntervalSince1970: TimeInterval(id)),
72 owner: Entity(canonicalName: "~owner"), 89 owner: Entity(canonicalName: owner),
73 head: Reference(name: "main", target: nil) 90 head: Reference(name: branch, target: nil)
74 ) 91 )
75 } 92 }
76} 93}
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 {
65 #expect(labelMatches.map(\.id) == [1]) 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 @Test 104 @Test
69 func filteredTicketsMatchesAnySelectedLabel() { 105 func filteredTicketsMatchesAnySelectedLabel() {
70 let tickets = [ 106 let tickets = [
@@ -153,7 +189,8 @@ struct TicketListViewModelTests {
153 title: String, 189 title: String,
154 status: TicketStatus, 190 status: TicketStatus,
155 submitter: String, 191 submitter: String,
156 labels: [TicketLabel] 192 labels: [TicketLabel],
193 assignees: [Entity] = []
157 ) -> TicketSummary { 194 ) -> TicketSummary {
158 TicketSummary( 195 TicketSummary(
159 id: id, 196 id: id,
@@ -163,7 +200,7 @@ struct TicketListViewModelTests {
163 created: Date(), 200 created: Date(),
164 submitter: Entity(canonicalName: submitter), 201 submitter: Entity(canonicalName: submitter),
165 labels: labels, 202 labels: labels,
166 assignees: [] 203 assignees: assignees
167 ) 204 )
168 } 205 }
169 206