summaryrefslogtreecommitdiff
path: root/Hutch/Views/Builds
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:52:33 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:52:33 -0500
commit72f860e0e171f6aac4e7d896e7a571147dbcb5c5 (patch)
treec62c9c9325292f65e6f72372a38b35760cc937b1 /Hutch/Views/Builds
parent576bb6c2432a15f5138771c06019b55fd2244e15 (diff)
downloadhutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.tar.gz
hutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.tar.bz2
hutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.zip
feat: add search and recent queries
Implements: https://todo.sr.ht/~ccleberg/hutch/47 Implements: https://todo.sr.ht/~ccleberg/hutch/48
Diffstat (limited to 'Hutch/Views/Builds')
-rw-r--r--Hutch/Views/Builds/BuildListView.swift28
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift52
2 files changed, 68 insertions, 12 deletions
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index 2423406..3bc3d99 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -167,8 +167,25 @@ struct BuildListView: View {
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search builds"
+ prompt: "Search builds by job ID, tag, note, or status"
)
+ .searchSuggestions {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ RecentSearchSuggestions(
+ title: "Recent Build Searches",
+ entries: viewModel.recentSearches
+ ) { query in
+ vm.searchText = query
+ } onClear: {
+ viewModel.clearRecentSearches()
+ }
+ }
+ }
+ .onSubmit(of: .search) {
+ let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return }
+ viewModel.recordRecentSearch(query)
+ }
.overlay {
if viewModel.isLoading, viewModel.jobs.isEmpty {
SRHTLoadingStateView(message: "Loading builds…")
@@ -178,8 +195,13 @@ struct BuildListView: View {
message: error,
retryAction: { await viewModel.loadJobs() }
)
- } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
- ContentUnavailableView.search(text: viewModel.searchText)
+ } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ viewModel.filteredJobs.isEmpty {
+ ContentUnavailableView(
+ "No Build Matches",
+ systemImage: "magnifyingglass",
+ description: Text("No builds matched “\(viewModel.searchText)”.")
+ )
} else if viewModel.jobs.isEmpty, viewModel.error == nil {
ContentUnavailableView(
"No Builds",
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index da29ca5..c6f8e49 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -44,8 +44,10 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
@Observable
@MainActor
final class BuildListViewModel {
+ private static let searchHistoryScopeID = "builds"
private(set) var jobs: [JobSummary] = []
+ private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var isRefreshing = false
@@ -60,13 +62,19 @@ final class BuildListViewModel {
private var cursor: String?
private var hasMore = true
private let client: SRHTClient
+ private let defaults: UserDefaults
private var refreshTask: Task<Void, Never>?
private var isAutoRefreshing = false
private static let cacheKey = "builds.jobs"
- init(client: SRHTClient) {
+ init(client: SRHTClient, defaults: UserDefaults = .standard) {
self.client = client
+ self.defaults = defaults
+ self.recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID,
+ defaults: defaults
+ )
}
/// Unique tags across all loaded jobs, sorted alphabetically.
@@ -82,14 +90,7 @@ final class BuildListViewModel {
result = result.filter { $0.tags.contains(repoFilter) }
}
- let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !q.isEmpty else { return result }
- return result.filter {
- String($0.id).contains(q) ||
- $0.tags.contains { $0.lowercased().contains(q) } ||
- ($0.note?.lowercased().contains(q) == true) ||
- ($0.image?.lowercased().contains(q) == true)
- }
+ return Self.searchJobs(result, matching: searchText)
}
// MARK: - Auto-Refresh
@@ -291,6 +292,26 @@ final class BuildListViewModel {
}
}
+ func recordRecentSearch(_ query: String) {
+ ScopedSearchHistoryStore.record(
+ query: query,
+ scopeID: Self.searchHistoryScopeID,
+ defaults: defaults
+ )
+ recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID,
+ defaults: defaults
+ )
+ }
+
+ func clearRecentSearches() {
+ ScopedSearchHistoryStore.clear(
+ scopeID: Self.searchHistoryScopeID,
+ defaults: defaults
+ )
+ recentSearches = []
+ }
+
// MARK: - Private
private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
@@ -363,4 +384,17 @@ final class BuildListViewModel {
}
}
}
+
+ nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
+ let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !normalizedQuery.isEmpty else { return jobs }
+
+ return jobs.filter {
+ String($0.id).contains(normalizedQuery) ||
+ $0.status.rawValue.lowercased().contains(normalizedQuery) ||
+ $0.tags.contains { $0.lowercased().contains(normalizedQuery) } ||
+ ($0.note?.lowercased().contains(normalizedQuery) == true) ||
+ ($0.image?.lowercased().contains(normalizedQuery) == true)
+ }
+ }
}