summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-22 20:42:45 -0500
committerChristian Cleberg <[email protected]>2026-03-22 20:42:45 -0500
commit1f8b7a23529462c3c332753d33810b5c9f692bbc (patch)
tree17526dae77a7579b270e6b9cca6224c9020f772d
parente03791d6c15ee0caac033d1b1b390c5d65a69d57 (diff)
downloadhutch-1f8b7a23529462c3c332753d33810b5c9f692bbc.tar.gz
hutch-1f8b7a23529462c3c332753d33810b5c9f692bbc.tar.bz2
hutch-1f8b7a23529462c3c332753d33810b5c9f692bbc.zip
feat: add search to builds, trackers, tickets, pastes, mailing lists, and inbox
-rw-r--r--Hutch/Views/Builds/BuildListView.swift21
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift18
-rw-r--r--Hutch/Views/Inbox/InboxView.swift9
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift11
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift19
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift7
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift12
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift38
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift8
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift80
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift9
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift11
-rw-r--r--HutchTests/BuildListViewModelTests.swift55
-rw-r--r--HutchTests/InboxViewModelTests.swift52
-rw-r--r--HutchTests/TrackerListViewModelTests.swift58
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
@@ -114,6 +114,58 @@ struct InboxViewModelTests {
}
@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 = """
From: Christian Cleberg <[email protected]>
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)
+ )
+ }
}