summaryrefslogtreecommitdiff
path: root/Hutch
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
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')
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Views/Builds/BuildListView.swift28
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift52
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift53
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift36
-rw-r--r--Hutch/Views/Search/RecentSearchSuggestions.swift26
-rw-r--r--Hutch/Views/Search/ScopedSearchHistoryStore.swift65
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift25
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift38
9 files changed, 303 insertions, 21 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 5bb8307..e151617 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -5,6 +5,7 @@ enum AppStorageKeys {
static let activeAccountID = "activeAccountID"
static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
static let lookupHistory = "lookupHistory"
+ static let scopedSearchHistory = "scopedSearchHistory"
static let hutchStatsBaseURL = "hutchStatsBaseURL"
static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
static let systemStatusIncidentCache = "systemStatusIncidentCache"
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)
+ }
+ }
}
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index d176e4b..f5ca716 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -4,6 +4,7 @@ struct RepositoryListView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: RepositoryListViewModel?
@State private var searchTask: Task<Void, Never>?
+ @State private var immediateSearchTask: Task<Void, Never>?
@State private var showCreateRepositorySheet = false
@State private var createdRepository: RepositorySummary?
@@ -68,6 +69,17 @@ struct RepositoryListView: View {
@Bindable var vm = viewModel
List {
+ if viewModel.isSearching {
+ HStack(spacing: 12) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Searching repositories…")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .listRowSeparator(.hidden)
+ }
+
ForEach(viewModel.repositories) { repo in
NavigationLink(value: repo) {
RepositoryRowView(
@@ -92,7 +104,28 @@ struct RepositoryListView: View {
}
.themedList()
.listStyle(.plain)
- .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
+ .searchable(
+ text: $vm.searchText,
+ placement: .navigationBarDrawer(displayMode: .always),
+ prompt: "Search repositories by name, owner, or description"
+ )
+ .searchSuggestions {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ RecentSearchSuggestions(
+ title: "Recent Repository Searches",
+ entries: viewModel.recentSearches
+ ) { query in
+ searchTask?.cancel()
+ immediateSearchTask?.cancel()
+ vm.searchText = query
+ immediateSearchTask = Task {
+ await viewModel.loadRepositories(search: query)
+ }
+ } onClear: {
+ viewModel.clearRecentSearches()
+ }
+ }
+ }
.overlay {
if viewModel.isLoading, viewModel.repositories.isEmpty {
SRHTLoadingStateView(message: "Loading repositories…")
@@ -110,7 +143,11 @@ struct RepositoryListView: View {
description: Text("You don't have any repositories yet.")
)
} else {
- ContentUnavailableView.search
+ ContentUnavailableView(
+ "No Repository Matches",
+ systemImage: "magnifyingglass",
+ description: Text("No repositories matched “\(viewModel.searchText)”.")
+ )
}
}
}
@@ -124,9 +161,21 @@ struct RepositoryListView: View {
.task {
await viewModel.loadRepositories()
}
+ .onSubmit(of: .search) {
+ let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return }
+
+ searchTask?.cancel()
+ immediateSearchTask?.cancel()
+ viewModel.recordRecentSearch(query)
+ immediateSearchTask = Task {
+ await viewModel.loadRepositories(search: query)
+ }
+ }
.onChange(of: viewModel.searchText) { oldValue, newValue in
// Cancel previous search task
searchTask?.cancel()
+ immediateSearchTask?.cancel()
// Clear results immediately when search text is cleared
if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 7a65441..0cfc8c3 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -25,9 +25,11 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
@Observable
@MainActor
final class RepositoryListViewModel {
+ private static let searchHistoryScopeID = "repositories"
private(set) var repositories: [RepositorySummary] = []
private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
+ private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var isRefreshing = false
@@ -42,6 +44,7 @@ final class RepositoryListViewModel {
private(set) var hasLoadedSearchIndex = false
private var searchIndex: [RepositorySummary] = []
private let client: SRHTClient
+ private let defaults: UserDefaults
private var buildStatusTask: Task<Void, Never>?
private static let gitCacheKey = "git.repositories"
@@ -49,8 +52,13 @@ final class RepositoryListViewModel {
private static let buildsCacheKey = "builds.repository-status"
private static let minimumRemoteSearchLength = 3
- init(client: SRHTClient) {
+ init(client: SRHTClient, defaults: UserDefaults = .standard) {
self.client = client
+ self.defaults = defaults
+ self.recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID,
+ defaults: defaults
+ )
}
// MARK: - Queries
@@ -302,6 +310,26 @@ final class RepositoryListViewModel {
isSearching = false
}
+ 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
/// Page shape matching the GraphQL response without generic constraints that
@@ -696,9 +724,13 @@ final class RepositoryListViewModel {
}
static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
- let lowercasedQuery = query.lowercased()
+ let lowercasedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !lowercasedQuery.isEmpty else { return repositories }
+
return repositories.filter { repo in
repo.name.lowercased().contains(lowercasedQuery) ||
+ repo.owner.canonicalName.lowercased().contains(lowercasedQuery) ||
+ repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false ||
repo.description?.lowercased().contains(lowercasedQuery) ?? false
}
}
diff --git a/Hutch/Views/Search/RecentSearchSuggestions.swift b/Hutch/Views/Search/RecentSearchSuggestions.swift
new file mode 100644
index 0000000..d40640e
--- /dev/null
+++ b/Hutch/Views/Search/RecentSearchSuggestions.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+struct RecentSearchSuggestions: View {
+ let title: String
+ let entries: [ScopedSearchHistoryEntry]
+ let onSelect: (String) -> Void
+ let onClear: () -> Void
+
+ var body: some View {
+ if !entries.isEmpty {
+ Section(title) {
+ ForEach(entries) { entry in
+ Button {
+ onSelect(entry.query)
+ } label: {
+ Label(entry.query, systemImage: "clock.arrow.circlepath")
+ }
+ }
+
+ Button("Clear Recent Searches", role: .destructive) {
+ onClear()
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Search/ScopedSearchHistoryStore.swift b/Hutch/Views/Search/ScopedSearchHistoryStore.swift
new file mode 100644
index 0000000..e009376
--- /dev/null
+++ b/Hutch/Views/Search/ScopedSearchHistoryStore.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+struct ScopedSearchHistoryEntry: Codable, Hashable, Identifiable, Sendable {
+ let scopeID: String
+ let query: String
+ let createdAt: Date
+
+ var id: String {
+ "\(scopeID):\(query.lowercased())"
+ }
+}
+
+enum ScopedSearchHistoryStore {
+ private static let maximumEntriesPerScope = 8
+
+ static func load(scopeID: String, defaults: UserDefaults = .standard) -> [ScopedSearchHistoryEntry] {
+ loadAll(defaults: defaults)[scopeID] ?? []
+ }
+
+ static func record(
+ query: String,
+ scopeID: String,
+ defaults: UserDefaults = .standard,
+ now: Date = .now
+ ) {
+ let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedQuery.isEmpty else { return }
+
+ var allEntries = loadAll(defaults: defaults)
+ var scopeEntries = allEntries[scopeID] ?? []
+ scopeEntries.removeAll {
+ $0.query.compare(normalizedQuery, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+ }
+ scopeEntries.insert(
+ ScopedSearchHistoryEntry(scopeID: scopeID, query: normalizedQuery, createdAt: now),
+ at: 0
+ )
+ allEntries[scopeID] = Array(scopeEntries.prefix(maximumEntriesPerScope))
+ save(allEntries, defaults: defaults)
+ }
+
+ static func clear(scopeID: String, defaults: UserDefaults = .standard) {
+ var allEntries = loadAll(defaults: defaults)
+ allEntries.removeValue(forKey: scopeID)
+ save(allEntries, defaults: defaults)
+ }
+
+ private static func loadAll(defaults: UserDefaults) -> [String: [ScopedSearchHistoryEntry]] {
+ guard let data = defaults.data(forKey: AppStorageKeys.scopedSearchHistory) else {
+ return [:]
+ }
+
+ do {
+ return try JSONDecoder().decode([String: [ScopedSearchHistoryEntry]].self, from: data)
+ } catch {
+ defaults.removeObject(forKey: AppStorageKeys.scopedSearchHistory)
+ return [:]
+ }
+ }
+
+ private static func save(_ entries: [String: [ScopedSearchHistoryEntry]], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ defaults.set(data, forKey: AppStorageKeys.scopedSearchHistory)
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index bd2236c..d2548d7 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -328,8 +328,25 @@ struct TicketListView: View {
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search tickets"
+ prompt: "Search tickets in \(tracker.name)"
)
+ .searchSuggestions {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ RecentSearchSuggestions(
+ title: "Recent Ticket 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.tickets.isEmpty {
SRHTLoadingStateView(message: "Loading tickets…")
@@ -341,7 +358,11 @@ struct TicketListView: View {
)
} else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
viewModel.filteredTickets.isEmpty {
- ContentUnavailableView.search(text: viewModel.searchText)
+ ContentUnavailableView(
+ "No Ticket Matches",
+ systemImage: "magnifyingglass",
+ description: Text("No tickets in \(tracker.name) matched “\(viewModel.searchText)”.")
+ )
} else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
ContentUnavailableView(
"No Tickets",
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index a2e7135..e7cc565 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -74,6 +74,10 @@ enum TicketFilter: String, CaseIterable, Codable, Sendable {
@Observable
@MainActor
final class TicketListViewModel {
+ private static func searchHistoryScopeID(for trackerRid: String) -> String {
+ "tickets.\(trackerRid)"
+ }
+
let ownerUsername: String
let trackerName: String
let trackerId: Int
@@ -85,6 +89,7 @@ final class TicketListViewModel {
private(set) var isCreatingTicket = false
private(set) var isPerformingAction = false
private(set) var trackerLabels: [TicketLabel] = []
+ private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var savedFilters: [SavedTicketFilter]
private(set) var isSelectionMode = false
private(set) var selectedTicketIDs: Set<Int> = []
@@ -123,10 +128,15 @@ final class TicketListViewModel {
self.defaults = defaults
let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
+ let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
self.filter = restoredState.status
self.selectedLabelIDs = Set(restoredState.labelIDs)
- self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
- self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id
+ self.savedFilters = savedFilters
+ self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id
+ self.recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
}
// MARK: - Query
@@ -505,6 +515,26 @@ final class TicketListViewModel {
selectedLabelIDs = []
}
+ func recordRecentSearch(_ query: String) {
+ ScopedSearchHistoryStore.record(
+ query: query,
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ }
+
+ func clearRecentSearches() {
+ ScopedSearchHistoryStore.clear(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ recentSearches = []
+ }
+
func applySavedFilter(_ savedFilter: SavedTicketFilter) {
filter = savedFilter.state.status
selectedLabelIDs = Set(savedFilter.state.labelIDs)
@@ -938,7 +968,9 @@ final class TicketListViewModel {
String($0.id).contains(q) ||
$0.title.lowercased().contains(q) ||
$0.submitter.canonicalName.lowercased().contains(q) ||
- $0.labels.contains { $0.name.lowercased().contains(q) }
+ $0.labels.contains { $0.name.lowercased().contains(q) } ||
+ $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
+ $0.status.displayName.lowercased().contains(q)
}
}
}