krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

72f860e0e171f6aac4e7d896e7a571147dbcb5c5

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T03:52:33Z

feat: add search and recent queries

Implements: https://todo.sr.ht/~ccleberg/hutch/47
Implements: https://todo.sr.ht/~ccleberg/hutch/48
 Hutch.xcodeproj/project.pbxproj                    | 16 +++---
 Hutch/App/AppStorageKeys.swift                     |  1 +
 Hutch/Views/Builds/BuildListView.swift             | 28 +++++++++-
 Hutch/Views/Builds/BuildListViewModel.swift        | 52 ++++++++++++++---
 Hutch/Views/Repositories/RepositoryListView.swift  | 53 +++++++++++++++++-
 .../Repositories/RepositoryListViewModel.swift     | 36 +++++++++++-
 Hutch/Views/Search/RecentSearchSuggestions.swift   | 26 +++++++++
 Hutch/Views/Search/ScopedSearchHistoryStore.swift  | 65 ++++++++++++++++++++++
 Hutch/Views/Tickets/TicketListView.swift           | 25 ++++++++-
 Hutch/Views/Tickets/TicketListViewModel.swift      | 38 ++++++++++++-
 HutchTests/BuildListViewModelTests.swift           | 21 ++++---
 HutchTests/RepositoryListViewModelTests.swift      | 23 +++++++-
 HutchTests/ScopedSearchHistoryStoreTests.swift     | 54 ++++++++++++++++++
 HutchTests/TicketListViewModelTests.swift          | 41 +++++++++++++-
 14 files changed, 437 insertions(+), 42 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index c719ba2..a39ce78 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 46;
+				CURRENT_PROJECT_VERSION = 49;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.3;
+				MARKETING_VERSION = 2.20.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 46;
+				CURRENT_PROJECT_VERSION = 49;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.3;
+				MARKETING_VERSION = 2.20.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 46;
+				CURRENT_PROJECT_VERSION = 49;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.3;
+				MARKETING_VERSION = 2.20.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 46;
+				CURRENT_PROJECT_VERSION = 49;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.3;
+				MARKETING_VERSION = 2.20.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
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)
         }
     }
 }
diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift
index a8cdc30..fccd7cf 100644
--- a/HutchTests/BuildListViewModelTests.swift
+++ b/HutchTests/BuildListViewModelTests.swift
@@ -28,6 +28,18 @@ struct BuildListViewModelTests {
         #expect(filtered.map(\.id) == [42])
     }
 
+    @Test
+    func filteredJobsMatchesByStatus() {
+        let jobs = [
+            makeJob(id: 1, status: .running, tags: []),
+            makeJob(id: 2, status: .success, tags: [])
+        ]
+
+        let filtered = filterJobs(jobs, query: "running")
+
+        #expect(filtered.map(\.id) == [1])
+    }
+
     @Test
     func buildFilterPrioritizesActionableStates() {
         let jobs = [
@@ -45,14 +57,7 @@ struct BuildListViewModelTests {
     }
 
     private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
-        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
-        guard !q.isEmpty else { return jobs }
-        return jobs.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)
-        }
+        BuildListViewModel.searchJobs(jobs, matching: query)
     }
 
     private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift
index 7bd9126..07f66aa 100644
--- a/HutchTests/RepositoryListViewModelTests.swift
+++ b/HutchTests/RepositoryListViewModelTests.swift
@@ -28,6 +28,21 @@ struct RepositoryListViewModelTests {
         #expect(descriptionMatches.map(\.id) == [2])
     }
 
+    @Test
+    @MainActor
+    func filterRepositoriesMatchesOwnerAndDefaultBranchLocally() {
+        let repositories = [
+            makeRepository(id: 1, service: .git, name: "Hutch", description: nil, owner: "~alice", branch: "main"),
+            makeRepository(id: 2, service: .hg, name: "Mail", description: nil, owner: "~bob", branch: "stable")
+        ]
+
+        let ownerMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "~bob")
+        let branchMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "main")
+
+        #expect(ownerMatches.map(\.id) == [2])
+        #expect(branchMatches.map(\.id) == [1])
+    }
+
     @Test
     func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() {
         let manifest = """
@@ -59,7 +74,9 @@ struct RepositoryListViewModelTests {
         id: Int,
         service: SRHTService,
         name: String,
-        description: String?
+        description: String?,
+        owner: String = "~owner",
+        branch: String = "main"
     ) -> RepositorySummary {
         RepositorySummary(
             id: id,
@@ -69,8 +86,8 @@ struct RepositoryListViewModelTests {
             description: description,
             visibility: .public,
             updated: Date(timeIntervalSince1970: TimeInterval(id)),
-            owner: Entity(canonicalName: "~owner"),
-            head: Reference(name: "main", target: nil)
+            owner: Entity(canonicalName: owner),
+            head: Reference(name: branch, target: nil)
         )
     }
 }
diff --git a/HutchTests/ScopedSearchHistoryStoreTests.swift b/HutchTests/ScopedSearchHistoryStoreTests.swift
new file mode 100644
index 0000000..856d69f
--- /dev/null
+++ b/HutchTests/ScopedSearchHistoryStoreTests.swift
@@ -0,0 +1,54 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ScopedSearchHistoryStoreTests {
+
+    @Test
+    func recordsMostRecentUniqueSearchPerScopeFirst() {
+        let suiteName = "ScopedSearchHistoryStoreTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        ScopedSearchHistoryStore.record(
+            query: "hutch",
+            scopeID: "repositories",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 100)
+        )
+        ScopedSearchHistoryStore.record(
+            query: "running",
+            scopeID: "builds",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 200)
+        )
+        ScopedSearchHistoryStore.record(
+            query: "Hutch",
+            scopeID: "repositories",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 300)
+        )
+
+        let repositoryHistory = ScopedSearchHistoryStore.load(scopeID: "repositories", defaults: defaults)
+        let buildHistory = ScopedSearchHistoryStore.load(scopeID: "builds", defaults: defaults)
+
+        #expect(repositoryHistory.map(\.query) == ["Hutch"])
+        #expect(repositoryHistory.first?.createdAt == Date(timeIntervalSince1970: 300))
+        #expect(buildHistory.map(\.query) == ["running"])
+    }
+
+    @Test
+    func clearsOnlyRequestedScope() {
+        let suiteName = "ScopedSearchHistoryStoreTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        ScopedSearchHistoryStore.record(query: "repo", scopeID: "repositories", defaults: defaults)
+        ScopedSearchHistoryStore.record(query: "ticket", scopeID: "tickets.rid", defaults: defaults)
+
+        ScopedSearchHistoryStore.clear(scopeID: "repositories", defaults: defaults)
+
+        #expect(ScopedSearchHistoryStore.load(scopeID: "repositories", defaults: defaults).isEmpty)
+        #expect(ScopedSearchHistoryStore.load(scopeID: "tickets.rid", defaults: defaults).map(\.query) == ["ticket"])
+    }
+}
diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift
index a237071..7fa5449 100644
--- a/HutchTests/TicketListViewModelTests.swift
+++ b/HutchTests/TicketListViewModelTests.swift
@@ -65,6 +65,42 @@ struct TicketListViewModelTests {
         #expect(labelMatches.map(\.id) == [1])
     }
 
+    @Test
+    func filteredTicketsMatchesAssigneeAndStatus() {
+        let tickets = [
+            makeTicket(
+                id: 1,
+                title: "Crash on launch",
+                status: .inProgress,
+                submitter: "~owner",
+                labels: [],
+                assignees: [Entity(canonicalName: "~alice")]
+            ),
+            makeTicket(
+                id: 2,
+                title: "Settings polish",
+                status: .resolved,
+                submitter: "~owner",
+                labels: [],
+                assignees: []
+            )
+        ]
+
+        let assigneeMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "~alice"
+        )
+        let statusMatches = filterTickets(
+            tickets,
+            state: TicketListFilterState(status: .all),
+            query: "resolved"
+        )
+
+        #expect(assigneeMatches.map(\.id) == [1])
+        #expect(statusMatches.map(\.id) == [2])
+    }
+
     @Test
     func filteredTicketsMatchesAnySelectedLabel() {
         let tickets = [
@@ -153,7 +189,8 @@ struct TicketListViewModelTests {
         title: String,
         status: TicketStatus,
         submitter: String,
-        labels: [TicketLabel]
+        labels: [TicketLabel],
+        assignees: [Entity] = []
     ) -> TicketSummary {
         TicketSummary(
             id: id,
@@ -163,7 +200,7 @@ struct TicketListViewModelTests {
             created: Date(),
             submitter: Entity(canonicalName: submitter),
             labels: labels,
-            assignees: []
+            assignees: assignees
         )
     }