krz/hutch

an ios client for sourcehut

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

1f8b7a23529462c3c332753d33810b5c9f692bbc

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-23T01:42:45Z

feat: add search to builds, trackers, tickets, pastes, mailing lists, and inbox
 Hutch/Views/Builds/BuildListView.swift            | 21 +++++-
 Hutch/Views/Builds/BuildListViewModel.swift       | 18 +++++
 Hutch/Views/Inbox/InboxView.swift                 |  9 ++-
 Hutch/Views/Inbox/InboxViewModel.swift            | 11 ++++
 Hutch/Views/Lists/MailingListListView.swift       | 19 +++++-
 Hutch/Views/Pastes/PasteListView.swift            |  7 ++
 Hutch/Views/Pastes/PasteListViewModel.swift       | 12 ++++
 Hutch/Views/Projects/ProjectMailingListView.swift | 38 ++++++++++-
 Hutch/Views/Tickets/TicketListView.swift          |  8 +++
 Hutch/Views/Tickets/TicketListViewModel.swift     | 80 +++++++++++++++++++++--
 Hutch/Views/Tickets/TrackerListView.swift         |  9 ++-
 Hutch/Views/Tickets/TrackerListViewModel.swift    | 11 ++++
 HutchTests/BuildListViewModelTests.swift          | 55 ++++++++++++++++
 HutchTests/InboxViewModelTests.swift              | 52 +++++++++++++++
 HutchTests/TrackerListViewModelTests.swift        | 58 ++++++++++++++++
 15 files changed, 398 insertions(+), 10 deletions(-)

diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index 77abe87..e248ca8 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -64,10 +64,22 @@ struct BuildListView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.jobs) { job in
+            ForEach(viewModel.filteredJobs) { job in
                 NavigationLink(value: job) {
                     BuildRowView(job: job)
                 }
+                .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                    if swipeActionsEnabled, job.status.isCancellable {
+                        Button {
+                            Task {
+                                await viewModel.cancelJob(job)
+                            }
+                        } label: {
+                            Label("Cancel", systemImage: "xmark.circle")
+                        }
+                        .tint(.red)
+                    }
+                }
                 .task {
                     await viewModel.loadMoreIfNeeded(currentItem: job)
                 }
@@ -83,6 +95,11 @@ struct BuildListView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search builds"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.jobs.isEmpty {
                 SRHTLoadingStateView(message: "Loading builds…")
@@ -92,6 +109,8 @@ 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.jobs.isEmpty, viewModel.error == nil {
                 ContentUnavailableView(
                     "No Builds",
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index 12f6e69..7b0b0b9 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -31,6 +31,7 @@ final class BuildListViewModel {
     private(set) var isRefreshing = false
     private(set) var isSubmitting = false
     var error: String?
+    var searchText = ""
 
     private var cursor: String?
     private var hasMore = true
@@ -42,6 +43,17 @@ final class BuildListViewModel {
         self.client = client
     }
 
+    var filteredJobs: [JobSummary] {
+        let q = searchText.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)
+        }
+    }
+
     // MARK: - Query
 
     private static let query = """
@@ -71,6 +83,12 @@ final class BuildListViewModel {
     }
     """
 
+    private static let cancelMutation = """
+    mutation cancel($id: Int!) {
+        cancel(jobId: $id) { id }
+    }
+    """
+
     // MARK: - Public API
 
     /// Fetch the first page of jobs. Shows cached data instantly if available,
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 74f78d5..5724964 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -30,7 +30,7 @@ struct InboxView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.threads) { thread in
+            ForEach(viewModel.filteredThreads) { thread in
                 Button {
                     selectThread(thread)
                 } label: {
@@ -45,6 +45,11 @@ struct InboxView: View {
                 }
             }
         }
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search inbox"
+        )
         .listStyle(.plain)
         .overlay {
             if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -55,6 +60,8 @@ struct InboxView: View {
                     message: error,
                     retryAction: { await viewModel.loadThreads() }
                 )
+            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
             } else if viewModel.threads.isEmpty, viewModel.error == nil {
                 ContentUnavailableView(
                     "Inbox Zero",
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 00d47b5..42ce223 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -62,6 +62,7 @@ final class InboxViewModel {
     private(set) var threads: [InboxThreadSummary] = []
     private(set) var isLoading = false
     var error: String?
+    var searchText = ""
 
     private let client: SRHTClient
     private let listThreadFetchLimit = 10
@@ -164,6 +165,16 @@ final class InboxViewModel {
         threads.first(where: { $0.id == id })
     }
 
+    var filteredThreads: [InboxThreadSummary] {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return threads }
+        return threads.filter {
+            $0.displaySubject.lowercased().contains(q) ||
+            $0.listName.lowercased().contains(q) ||
+            $0.latestSender.canonicalName.lowercased().contains(q)
+        }
+    }
+
     private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
         var subscriptions: [InboxActivitySubscription] = []
         var cursor: String?
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 80d4e50..5b8a7b0 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -6,6 +6,7 @@ final class MailingListListViewModel {
     private(set) var mailingLists: [InboxMailingListReference] = []
     private(set) var isLoading = false
     var error: String?
+    var searchText = ""
 
     private let client: SRHTClient
 
@@ -31,6 +32,15 @@ final class MailingListListViewModel {
         self.client = client
     }
 
+    var filteredMailingLists: [InboxMailingListReference] {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return mailingLists }
+        return mailingLists.filter {
+            $0.name.lowercased().contains(q) ||
+            $0.owner.canonicalName.lowercased().contains(q)
+        }
+    }
+
     func loadMailingLists() async {
         guard !isLoading else { return }
         isLoading = true
@@ -120,7 +130,7 @@ struct MailingListListView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.mailingLists, id: \.rid) { mailingList in
+            ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
                 NavigationLink(value: MoreRoute.mailingList(mailingList)) {
                     VStack(alignment: .leading, spacing: 4) {
                         Text(mailingList.name)
@@ -134,6 +144,11 @@ struct MailingListListView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search lists"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.mailingLists.isEmpty {
                 SRHTLoadingStateView(message: "Loading mailing lists…")
@@ -143,6 +158,8 @@ struct MailingListListView: View {
                     message: error,
                     retryAction: { await viewModel.loadMailingLists() }
                 )
+            } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
             } else if viewModel.mailingLists.isEmpty {
                 ContentUnavailableView(
                     "No Mailing Lists",
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index 9d8c0d4..84f2926 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -87,6 +87,11 @@ struct PasteListView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search pastes"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.pastes.isEmpty {
                 SRHTLoadingStateView(message: "Loading pastes…")
@@ -96,6 +101,8 @@ struct PasteListView: View {
                     message: error,
                     retryAction: { await viewModel.loadPastes() }
                 )
+            } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
             } else if viewModel.pastes.isEmpty {
                 ContentUnavailableView(
                     "No Pastes",
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
index cc2180d..d41cf96 100644
--- a/Hutch/Views/Pastes/PasteListViewModel.swift
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -9,6 +9,7 @@ final class PasteListViewModel {
     private(set) var isRefreshing = false
     private(set) var isCreatingPaste = false
     var error: String?
+    var searchText = ""
 
     private var cursor: String?
     private var hasMore = true
@@ -18,6 +19,17 @@ final class PasteListViewModel {
         self.service = service
     }
 
+    var filteredPastes: [Paste] {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return pastes }
+        return pastes.filter {
+            $0.files.contains {
+                ($0.filename?.lowercased().contains(q) == true) ||
+                $0.hash.lowercased().hasPrefix(q)
+            }
+        }
+    }
+
     func loadPastes() async {
         if pastes.isEmpty, let cached = service.loadCachedPastes() {
             pastes = cached.results
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index df0ffe1..e044efb 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -32,6 +32,7 @@ final class MailingListDetailViewModel {
     private(set) var threads: [InboxThreadSummary] = []
     private(set) var isLoading = false
     var error: String?
+    var searchText = ""
 
     private let mailingList: InboxMailingListReference
     private let client: SRHTClient
@@ -61,6 +62,10 @@ final class MailingListDetailViewModel {
         self.client = client
     }
 
+    var filteredThreads: [InboxThreadSummary] {
+        Self.filterThreads(threads, matching: searchText)
+    }
+
     func loadThreads() async {
         guard !isLoading else { return }
         isLoading = true
@@ -187,6 +192,30 @@ final class MailingListDetailViewModel {
             return lhs.lastActivityAt > rhs.lastActivityAt
         }
     }
+
+    nonisolated static func filterThreads(
+        _ threads: [InboxThreadSummary],
+        matching query: String
+    ) -> [InboxThreadSummary] {
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return threads }
+        return threads.filter {
+            normalizedSubject(from: $0.subject).contains(q) ||
+            $0.latestSender.canonicalName.lowercased().contains(q)
+        }
+    }
+
+    private nonisolated static func normalizedSubject(from subject: String) -> String {
+        subject
+            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(
+                of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
+                with: "",
+                options: [.regularExpression, .caseInsensitive]
+            )
+            .lowercased()
+    }
 }
 
 struct MailingListDetailView: View {
@@ -225,7 +254,7 @@ struct MailingListDetailView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.threads) { thread in
+            ForEach(viewModel.filteredThreads) { thread in
                 NavigationLink(value: MoreRoute.thread(thread)) {
                     InboxThreadRow(thread: thread)
                 }
@@ -249,6 +278,11 @@ struct MailingListDetailView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search messages"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.threads.isEmpty {
                 SRHTLoadingStateView(message: "Loading mailing list…")
@@ -258,6 +292,8 @@ struct MailingListDetailView: View {
                     message: error,
                     retryAction: { await viewModel.loadThreads() }
                 )
+            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
             } else if viewModel.threads.isEmpty {
                 ContentUnavailableView(
                     "No Threads",
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index ef0da23..93a7032 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -108,6 +108,11 @@ struct TicketListView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search tickets"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.tickets.isEmpty {
                 SRHTLoadingStateView(message: "Loading tickets…")
@@ -117,6 +122,9 @@ struct TicketListView: View {
                     message: error,
                     retryAction: { await viewModel.loadTickets() }
                 )
+            } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+                      viewModel.filteredTickets.isEmpty {
+                ContentUnavailableView.search(text: 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 f3c9416..303fd66 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -35,23 +35,40 @@ final class TicketListViewModel {
     let ownerUsername: String
     let trackerName: String
     let trackerId: Int
+    let trackerRid: String
 
     private(set) var tickets: [TicketSummary] = []
     private(set) var isLoading = false
     private(set) var isLoadingMore = false
     private(set) var isCreatingTicket = false
+    private(set) var isPerformingAction = false
+    private(set) var trackerLabels: [TicketLabel] = []
     var error: String?
-    var filter: TicketFilter = .open
+    var filter: TicketFilter = .open {
+        didSet {
+            UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
+        }
+    }
+    var searchText = ""
 
     private var cursor: String?
     private var hasMore = true
     private let client: SRHTClient
 
-    init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
+    private var filterDefaultsKey: String {
+        "ticketFilter_\(trackerRid)"
+    }
+
+    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
         self.ownerUsername = ownerUsername
         self.trackerName = trackerName
         self.trackerId = trackerId
+        self.trackerRid = trackerRid
         self.client = client
+        if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
+           let restored = TicketFilter(rawValue: raw) {
+            self.filter = restored
+        }
     }
 
     // MARK: - Query
@@ -93,17 +110,70 @@ final class TicketListViewModel {
     }
     """
 
+    private static let updateStatusMutation = """
+    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
+        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+            eventType: __typename
+        }
+    }
+    """
+
+    private static let assignUserMutation = """
+    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+    }
+    """
+
+    private static let unassignUserMutation = """
+    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+    }
+    """
+
+    private static let labelTicketMutation = """
+    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+    }
+    """
+
+    private static let unlabelTicketMutation = """
+    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+    }
+    """
+
+    private static let trackerLabelsQuery = """
+    query trackerLabels($owner: String!, $tracker: String!) {
+        user(username: $owner) {
+            tracker(name: $tracker) {
+                labels {
+                    results { id name backgroundColor foregroundColor }
+                }
+            }
+        }
+    }
+    """
+
     // MARK: - Computed
 
     /// Tickets filtered by the selected status filter.
     var filteredTickets: [TicketSummary] {
+        let statusFiltered: [TicketSummary]
         switch filter {
         case .open:
-            tickets.filter { $0.status.isOpen }
+            statusFiltered = tickets.filter { $0.status.isOpen }
         case .resolved:
-            tickets.filter { !$0.status.isOpen }
+            statusFiltered = tickets.filter { !$0.status.isOpen }
         case .all:
-            tickets
+            statusFiltered = tickets
+        }
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return statusFiltered }
+        return statusFiltered.filter {
+            String($0.id).contains(q) ||
+            $0.title.lowercased().contains(q) ||
+            $0.submitter.canonicalName.lowercased().contains(q) ||
+            $0.labels.contains { $0.name.lowercased().contains(q) }
         }
     }
 
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index ee253ec..a324eae 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -65,7 +65,7 @@ struct TrackerListView: View {
         @Bindable var vm = viewModel
 
         List {
-            ForEach(viewModel.trackers) { tracker in
+            ForEach(viewModel.filteredTrackers) { tracker in
                 NavigationLink(value: tracker) {
                     TrackerRowView(tracker: tracker)
                 }
@@ -84,6 +84,11 @@ struct TrackerListView: View {
             }
         }
         .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search trackers"
+        )
         .overlay {
             if viewModel.isLoading, viewModel.trackers.isEmpty {
                 SRHTLoadingStateView(message: "Loading trackers…")
@@ -93,6 +98,8 @@ struct TrackerListView: View {
                     message: error,
                     retryAction: { await viewModel.loadTrackers() }
                 )
+            } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
             } else if viewModel.trackers.isEmpty, viewModel.error == nil {
                 ContentUnavailableView(
                     "No Trackers",
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
index 9c241ae..31c07d7 100644
--- a/Hutch/Views/Tickets/TrackerListViewModel.swift
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -22,6 +22,7 @@ final class TrackerListViewModel {
     private(set) var isLoadingMore = false
     private(set) var isCreatingTracker = false
     var error: String?
+    var searchText = ""
 
     private var cursor: String?
     private var hasMore = true
@@ -31,6 +32,16 @@ final class TrackerListViewModel {
         self.client = client
     }
 
+    var filteredTrackers: [TrackerSummary] {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return trackers }
+        return trackers.filter {
+            $0.name.lowercased().contains(q) ||
+            ($0.description?.lowercased().contains(q) == true) ||
+            $0.owner.canonicalName.lowercased().contains(q)
+        }
+    }
+
     // MARK: - Query
 
     private static let query = """
diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift
new file mode 100644
index 0000000..a5c2483
--- /dev/null
+++ b/HutchTests/BuildListViewModelTests.swift
@@ -0,0 +1,55 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct BuildListViewModelTests {
+
+    @Test
+    func filteredJobsReturnsAllWhenSearchTextIsEmpty() {
+        let jobs = [makeJob(id: 1, tags: ["ci"]), makeJob(id: 2, tags: [])]
+        let filtered = filterJobs(jobs, query: "")
+
+        #expect(filtered.count == 2)
+    }
+
+    @Test
+    func filteredJobsMatchesByTag() {
+        let jobs = [makeJob(id: 1, tags: ["ci", "deploy"]), makeJob(id: 2, tags: ["lint"])]
+        let filtered = filterJobs(jobs, query: "deploy")
+
+        #expect(filtered.map(\.id) == [1])
+    }
+
+    @Test
+    func filteredJobsMatchesByJobId() {
+        let jobs = [makeJob(id: 42, tags: []), makeJob(id: 99, tags: [])]
+        let filtered = filterJobs(jobs, query: "42")
+
+        #expect(filtered.map(\.id) == [42])
+    }
+
+    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)
+        }
+    }
+
+    private func makeJob(id: Int, tags: [String]) -> JobSummary {
+        JobSummary(
+            id: id,
+            created: Date(),
+            updated: Date(),
+            status: .success,
+            note: nil,
+            tags: tags,
+            visibility: nil,
+            image: nil,
+            tasks: []
+        )
+    }
+}
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
index b202d03..597bbc2 100644
--- a/HutchTests/InboxViewModelTests.swift
+++ b/HutchTests/InboxViewModelTests.swift
@@ -113,6 +113,58 @@ struct InboxViewModelTests {
         #expect(first.id != second.id)
     }
 
+    @Test
+    func mailingListThreadFilterMatchesSubjectAndSender() {
+        let baseList = InboxMailingListReference(
+            id: 1,
+            rid: "list",
+            name: "hutch-devel",
+            owner: Entity(canonicalName: "~owner")
+        )
+        let threads = [
+            InboxThreadSummary(
+                rootEmailID: 10,
+                rootMessageID: "message-1",
+                threadRootEmailIDs: [10],
+                threadRootMessageIDs: ["message-1"],
+                listID: baseList.id,
+                listRID: baseList.rid,
+                listName: baseList.name,
+                listOwner: baseList.owner,
+                subject: "Re: [PATCH] add search",
+                latestSender: Entity(canonicalName: "~alice"),
+                lastActivityAt: Date(timeIntervalSince1970: 100),
+                messageCount: 1,
+                repo: "hutch",
+                containsPatch: true,
+                isUnread: true
+            ),
+            InboxThreadSummary(
+                rootEmailID: 11,
+                rootMessageID: "message-2",
+                threadRootEmailIDs: [11],
+                threadRootMessageIDs: ["message-2"],
+                listID: baseList.id,
+                listRID: baseList.rid,
+                listName: baseList.name,
+                listOwner: baseList.owner,
+                subject: "Release planning",
+                latestSender: Entity(canonicalName: "~bob"),
+                lastActivityAt: Date(timeIntervalSince1970: 200),
+                messageCount: 2,
+                repo: "hutch",
+                containsPatch: false,
+                isUnread: true
+            )
+        ]
+
+        let subjectMatches = MailingListDetailViewModel.filterThreads(threads, matching: "search")
+        let senderMatches = MailingListDetailViewModel.filterThreads(threads, matching: "~bob")
+
+        #expect(subjectMatches.map(\.rootEmailID) == [10])
+        #expect(senderMatches.map(\.rootEmailID) == [11])
+    }
+
     @Test
     func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
         let body = """
diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift
index 1caedea..7e927af 100644
--- a/HutchTests/TrackerListViewModelTests.swift
+++ b/HutchTests/TrackerListViewModelTests.swift
@@ -13,4 +13,62 @@ struct TrackerListViewModelTests {
 
         #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
     }
+
+    @Test
+    func filteredTrackersReturnsAllWhenSearchTextIsEmpty() {
+        let trackers = [
+            makeTracker(id: 1, name: "bugs", description: "Bug tracker", owner: "~owner"),
+            makeTracker(id: 2, name: "ideas", description: nil, owner: "~team")
+        ]
+
+        let filtered = filterTrackers(trackers, query: "")
+
+        #expect(filtered.count == 2)
+    }
+
+    @Test
+    func filteredTrackersMatchesDescription() {
+        let trackers = [
+            makeTracker(id: 1, name: "bugs", description: "Production incidents", owner: "~owner"),
+            makeTracker(id: 2, name: "ideas", description: "Feature requests", owner: "~team")
+        ]
+
+        let filtered = filterTrackers(trackers, query: "incident")
+
+        #expect(filtered.map(\.id) == [1])
+    }
+
+    @Test
+    func filteredTrackersMatchesOwner() {
+        let trackers = [
+            makeTracker(id: 1, name: "bugs", description: nil, owner: "~owner"),
+            makeTracker(id: 2, name: "ideas", description: nil, owner: "~team")
+        ]
+
+        let filtered = filterTrackers(trackers, query: "~team")
+
+        #expect(filtered.map(\.id) == [2])
+    }
+
+    private func filterTrackers(_ trackers: [TrackerSummary], query: String) -> [TrackerSummary] {
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !q.isEmpty else { return trackers }
+        return trackers.filter {
+            $0.name.lowercased().contains(q) ||
+            ($0.description?.lowercased().contains(q) == true) ||
+            $0.owner.canonicalName.lowercased().contains(q)
+        }
+    }
+
+    private func makeTracker(id: Int, name: String, description: String?, owner: String) -> TrackerSummary {
+        TrackerSummary(
+            id: id,
+            rid: "rid-\(id)",
+            name: name,
+            description: description,
+            visibility: .public,
+            updated: Date(),
+            owner: Entity(canonicalName: owner)
+        )
+    }
 }