Commit e3f8c2de0f

e3f8c2de0f2dd236adc9f7d5d80ebdf175bfad54

parent: bc351b2fcd

Unsigned

cmc <hello@cleberg.net> · 2026-03-23 01:42 UTC

feat: add search to builds, trackers, tickets, pastes, mailing lists, and inbox

Layout: unified · split

Hutch/Views/Builds/BuildListView.swift +20 −1
@@ -64,10 +64,22 @@ struct BuildListView: View {
6464 @Bindable var vm = viewModel
6565
6666 List {
67 ForEach(viewModel.jobs) { job in
67 ForEach(viewModel.filteredJobs) { job in
6868 NavigationLink(value: job) {
6969 BuildRowView(job: job)
7070 }
71 .swipeActions(edge: .leading, allowsFullSwipe: true) {
72 if swipeActionsEnabled, job.status.isCancellable {
73 Button {
74 Task {
75 await viewModel.cancelJob(job)
76 }
77 } label: {
78 Label("Cancel", systemImage: "xmark.circle")
79 }
80 .tint(.red)
81 }
82 }
7183 .task {
7284 await viewModel.loadMoreIfNeeded(currentItem: job)
7385 }
@@ -83,6 +95,11 @@ struct BuildListView: View {
8395 }
8496 }
8597 .listStyle(.plain)
98 .searchable(
99 text: $vm.searchText,
100 placement: .navigationBarDrawer(displayMode: .always),
101 prompt: "Search builds"
102 )
86103 .overlay {
87104 if viewModel.isLoading, viewModel.jobs.isEmpty {
88105 SRHTLoadingStateView(message: "Loading builds…")
@@ -92,6 +109,8 @@ struct BuildListView: View {
92109 message: error,
93110 retryAction: { await viewModel.loadJobs() }
94111 )
112 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
113 ContentUnavailableView.search(text: viewModel.searchText)
95114 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
96115 ContentUnavailableView(
97116 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +18
@@ -31,6 +31,7 @@ final class BuildListViewModel {
3131 private(set) var isRefreshing = false
3232 private(set) var isSubmitting = false
3333 var error: String?
34 var searchText = ""
3435
3536 private var cursor: String?
3637 private var hasMore = true
@@ -42,6 +43,17 @@ final class BuildListViewModel {
4243 self.client = client
4344 }
4445
46 var filteredJobs: [JobSummary] {
47 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
48 guard !q.isEmpty else { return jobs }
49 return jobs.filter {
50 String($0.id).contains(q) ||
51 $0.tags.contains { $0.lowercased().contains(q) } ||
52 ($0.note?.lowercased().contains(q) == true) ||
53 ($0.image?.lowercased().contains(q) == true)
54 }
55 }
56
4557 // MARK: - Query
4658
4759 private static let query = """
@@ -71,6 +83,12 @@ final class BuildListViewModel {
7183 }
7284 """
7385
86 private static let cancelMutation = """
87 mutation cancel($id: Int!) {
88 cancel(jobId: $id) { id }
89 }
90 """
91
7492 // MARK: - Public API
7593
7694 /// Fetch the first page of jobs. Shows cached data instantly if available,
Hutch/Views/Inbox/InboxView.swift +8 −1
@@ -30,7 +30,7 @@ struct InboxView: View {
3030 @Bindable var vm = viewModel
3131
3232 List {
33 ForEach(viewModel.threads) { thread in
33 ForEach(viewModel.filteredThreads) { thread in
3434 Button {
3535 selectThread(thread)
3636 } label: {
@@ -45,6 +45,11 @@ struct InboxView: View {
4545 }
4646 }
4747 }
48 .searchable(
49 text: $vm.searchText,
50 placement: .navigationBarDrawer(displayMode: .always),
51 prompt: "Search inbox"
52 )
4853 .listStyle(.plain)
4954 .overlay {
5055 if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -55,6 +60,8 @@ struct InboxView: View {
5560 message: error,
5661 retryAction: { await viewModel.loadThreads() }
5762 )
63 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
64 ContentUnavailableView.search(text: viewModel.searchText)
5865 } else if viewModel.threads.isEmpty, viewModel.error == nil {
5966 ContentUnavailableView(
6067 "Inbox Zero",
Hutch/Views/Inbox/InboxViewModel.swift +11
@@ -62,6 +62,7 @@ final class InboxViewModel {
6262 private(set) var threads: [InboxThreadSummary] = []
6363 private(set) var isLoading = false
6464 var error: String?
65 var searchText = ""
6566
6667 private let client: SRHTClient
6768 private let listThreadFetchLimit = 10
@@ -164,6 +165,16 @@ final class InboxViewModel {
164165 threads.first(where: { $0.id == id })
165166 }
166167
168 var filteredThreads: [InboxThreadSummary] {
169 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
170 guard !q.isEmpty else { return threads }
171 return threads.filter {
172 $0.displaySubject.lowercased().contains(q) ||
173 $0.listName.lowercased().contains(q) ||
174 $0.latestSender.canonicalName.lowercased().contains(q)
175 }
176 }
177
167178 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
168179 var subscriptions: [InboxActivitySubscription] = []
169180 var cursor: String?
Hutch/Views/Lists/MailingListListView.swift +18 −1
@@ -6,6 +6,7 @@ final class MailingListListViewModel {
66 private(set) var mailingLists: [InboxMailingListReference] = []
77 private(set) var isLoading = false
88 var error: String?
9 var searchText = ""
910
1011 private let client: SRHTClient
1112
@@ -31,6 +32,15 @@ final class MailingListListViewModel {
3132 self.client = client
3233 }
3334
35 var filteredMailingLists: [InboxMailingListReference] {
36 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
37 guard !q.isEmpty else { return mailingLists }
38 return mailingLists.filter {
39 $0.name.lowercased().contains(q) ||
40 $0.owner.canonicalName.lowercased().contains(q)
41 }
42 }
43
3444 func loadMailingLists() async {
3545 guard !isLoading else { return }
3646 isLoading = true
@@ -120,7 +130,7 @@ struct MailingListListView: View {
120130 @Bindable var vm = viewModel
121131
122132 List {
123 ForEach(viewModel.mailingLists, id: \.rid) { mailingList in
133 ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
124134 NavigationLink(value: MoreRoute.mailingList(mailingList)) {
125135 VStack(alignment: .leading, spacing: 4) {
126136 Text(mailingList.name)
@@ -134,6 +144,11 @@ struct MailingListListView: View {
134144 }
135145 }
136146 .listStyle(.plain)
147 .searchable(
148 text: $vm.searchText,
149 placement: .navigationBarDrawer(displayMode: .always),
150 prompt: "Search lists"
151 )
137152 .overlay {
138153 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
139154 SRHTLoadingStateView(message: "Loading mailing lists…")
@@ -143,6 +158,8 @@ struct MailingListListView: View {
143158 message: error,
144159 retryAction: { await viewModel.loadMailingLists() }
145160 )
161 } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
162 ContentUnavailableView.search(text: viewModel.searchText)
146163 } else if viewModel.mailingLists.isEmpty {
147164 ContentUnavailableView(
148165 "No Mailing Lists",
Hutch/Views/Pastes/PasteListView.swift +7
@@ -87,6 +87,11 @@ struct PasteListView: View {
8787 }
8888 }
8989 .listStyle(.plain)
90 .searchable(
91 text: $vm.searchText,
92 placement: .navigationBarDrawer(displayMode: .always),
93 prompt: "Search pastes"
94 )
9095 .overlay {
9196 if viewModel.isLoading, viewModel.pastes.isEmpty {
9297 SRHTLoadingStateView(message: "Loading pastes…")
@@ -96,6 +101,8 @@ struct PasteListView: View {
96101 message: error,
97102 retryAction: { await viewModel.loadPastes() }
98103 )
104 } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
105 ContentUnavailableView.search(text: viewModel.searchText)
99106 } else if viewModel.pastes.isEmpty {
100107 ContentUnavailableView(
101108 "No Pastes",
Hutch/Views/Pastes/PasteListViewModel.swift +12
@@ -9,6 +9,7 @@ final class PasteListViewModel {
99 private(set) var isRefreshing = false
1010 private(set) var isCreatingPaste = false
1111 var error: String?
12 var searchText = ""
1213
1314 private var cursor: String?
1415 private var hasMore = true
@@ -18,6 +19,17 @@ final class PasteListViewModel {
1819 self.service = service
1920 }
2021
22 var filteredPastes: [Paste] {
23 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
24 guard !q.isEmpty else { return pastes }
25 return pastes.filter {
26 $0.files.contains {
27 ($0.filename?.lowercased().contains(q) == true) ||
28 $0.hash.lowercased().hasPrefix(q)
29 }
30 }
31 }
32
2133 func loadPastes() async {
2234 if pastes.isEmpty, let cached = service.loadCachedPastes() {
2335 pastes = cached.results
Hutch/Views/Projects/ProjectMailingListView.swift +37 −1
@@ -32,6 +32,7 @@ final class MailingListDetailViewModel {
3232 private(set) var threads: [InboxThreadSummary] = []
3333 private(set) var isLoading = false
3434 var error: String?
35 var searchText = ""
3536
3637 private let mailingList: InboxMailingListReference
3738 private let client: SRHTClient
@@ -61,6 +62,10 @@ final class MailingListDetailViewModel {
6162 self.client = client
6263 }
6364
65 var filteredThreads: [InboxThreadSummary] {
66 Self.filterThreads(threads, matching: searchText)
67 }
68
6469 func loadThreads() async {
6570 guard !isLoading else { return }
6671 isLoading = true
@@ -187,6 +192,30 @@ final class MailingListDetailViewModel {
187192 return lhs.lastActivityAt > rhs.lastActivityAt
188193 }
189194 }
195
196 nonisolated static func filterThreads(
197 _ threads: [InboxThreadSummary],
198 matching query: String
199 ) -> [InboxThreadSummary] {
200 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
201 guard !q.isEmpty else { return threads }
202 return threads.filter {
203 normalizedSubject(from: $0.subject).contains(q) ||
204 $0.latestSender.canonicalName.lowercased().contains(q)
205 }
206 }
207
208 private nonisolated static func normalizedSubject(from subject: String) -> String {
209 subject
210 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
211 .trimmingCharacters(in: .whitespacesAndNewlines)
212 .replacingOccurrences(
213 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
214 with: "",
215 options: [.regularExpression, .caseInsensitive]
216 )
217 .lowercased()
218 }
190219}
191220
192221struct MailingListDetailView: View {
@@ -225,7 +254,7 @@ struct MailingListDetailView: View {
225254 @Bindable var vm = viewModel
226255
227256 List {
228 ForEach(viewModel.threads) { thread in
257 ForEach(viewModel.filteredThreads) { thread in
229258 NavigationLink(value: MoreRoute.thread(thread)) {
230259 InboxThreadRow(thread: thread)
231260 }
@@ -249,6 +278,11 @@ struct MailingListDetailView: View {
249278 }
250279 }
251280 .listStyle(.plain)
281 .searchable(
282 text: $vm.searchText,
283 placement: .navigationBarDrawer(displayMode: .always),
284 prompt: "Search messages"
285 )
252286 .overlay {
253287 if viewModel.isLoading, viewModel.threads.isEmpty {
254288 SRHTLoadingStateView(message: "Loading mailing list…")
@@ -258,6 +292,8 @@ struct MailingListDetailView: View {
258292 message: error,
259293 retryAction: { await viewModel.loadThreads() }
260294 )
295 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
296 ContentUnavailableView.search(text: viewModel.searchText)
261297 } else if viewModel.threads.isEmpty {
262298 ContentUnavailableView(
263299 "No Threads",
Hutch/Views/Tickets/TicketListView.swift +8
@@ -108,6 +108,11 @@ struct TicketListView: View {
108108 }
109109 }
110110 .listStyle(.plain)
111 .searchable(
112 text: $vm.searchText,
113 placement: .navigationBarDrawer(displayMode: .always),
114 prompt: "Search tickets"
115 )
111116 .overlay {
112117 if viewModel.isLoading, viewModel.tickets.isEmpty {
113118 SRHTLoadingStateView(message: "Loading tickets…")
@@ -117,6 +122,9 @@ struct TicketListView: View {
117122 message: error,
118123 retryAction: { await viewModel.loadTickets() }
119124 )
125 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
126 viewModel.filteredTickets.isEmpty {
127 ContentUnavailableView.search(text: viewModel.searchText)
120128 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
121129 ContentUnavailableView(
122130 "No Tickets",
Hutch/Views/Tickets/TicketListViewModel.swift +75 −5
@@ -35,23 +35,40 @@ final class TicketListViewModel {
3535 let ownerUsername: String
3636 let trackerName: String
3737 let trackerId: Int
38 let trackerRid: String
3839
3940 private(set) var tickets: [TicketSummary] = []
4041 private(set) var isLoading = false
4142 private(set) var isLoadingMore = false
4243 private(set) var isCreatingTicket = false
44 private(set) var isPerformingAction = false
45 private(set) var trackerLabels: [TicketLabel] = []
4346 var error: String?
44 var filter: TicketFilter = .open
47 var filter: TicketFilter = .open {
48 didSet {
49 UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
50 }
51 }
52 var searchText = ""
4553
4654 private var cursor: String?
4755 private var hasMore = true
4856 private let client: SRHTClient
4957
50 init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
58 private var filterDefaultsKey: String {
59 "ticketFilter_\(trackerRid)"
60 }
61
62 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
5163 self.ownerUsername = ownerUsername
5264 self.trackerName = trackerName
5365 self.trackerId = trackerId
66 self.trackerRid = trackerRid
5467 self.client = client
68 if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
69 let restored = TicketFilter(rawValue: raw) {
70 self.filter = restored
71 }
5572 }
5673
5774 // MARK: - Query
@@ -93,17 +110,70 @@ final class TicketListViewModel {
93110 }
94111 """
95112
113 private static let updateStatusMutation = """
114 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
115 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
116 eventType: __typename
117 }
118 }
119 """
120
121 private static let assignUserMutation = """
122 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
123 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
124 }
125 """
126
127 private static let unassignUserMutation = """
128 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
129 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
130 }
131 """
132
133 private static let labelTicketMutation = """
134 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
135 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
136 }
137 """
138
139 private static let unlabelTicketMutation = """
140 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
141 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
142 }
143 """
144
145 private static let trackerLabelsQuery = """
146 query trackerLabels($owner: String!, $tracker: String!) {
147 user(username: $owner) {
148 tracker(name: $tracker) {
149 labels {
150 results { id name backgroundColor foregroundColor }
151 }
152 }
153 }
154 }
155 """
156
96157 // MARK: - Computed
97158
98159 /// Tickets filtered by the selected status filter.
99160 var filteredTickets: [TicketSummary] {
161 let statusFiltered: [TicketSummary]
100162 switch filter {
101163 case .open:
102 tickets.filter { $0.status.isOpen }
164 statusFiltered = tickets.filter { $0.status.isOpen }
103165 case .resolved:
104 tickets.filter { !$0.status.isOpen }
166 statusFiltered = tickets.filter { !$0.status.isOpen }
105167 case .all:
106 tickets
168 statusFiltered = tickets
169 }
170 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
171 guard !q.isEmpty else { return statusFiltered }
172 return statusFiltered.filter {
173 String($0.id).contains(q) ||
174 $0.title.lowercased().contains(q) ||
175 $0.submitter.canonicalName.lowercased().contains(q) ||
176 $0.labels.contains { $0.name.lowercased().contains(q) }
107177 }
108178 }
109179
Hutch/Views/Tickets/TrackerListView.swift +8 −1
@@ -65,7 +65,7 @@ struct TrackerListView: View {
6565 @Bindable var vm = viewModel
6666
6767 List {
68 ForEach(viewModel.trackers) { tracker in
68 ForEach(viewModel.filteredTrackers) { tracker in
6969 NavigationLink(value: tracker) {
7070 TrackerRowView(tracker: tracker)
7171 }
@@ -84,6 +84,11 @@ struct TrackerListView: View {
8484 }
8585 }
8686 .listStyle(.plain)
87 .searchable(
88 text: $vm.searchText,
89 placement: .navigationBarDrawer(displayMode: .always),
90 prompt: "Search trackers"
91 )
8792 .overlay {
8893 if viewModel.isLoading, viewModel.trackers.isEmpty {
8994 SRHTLoadingStateView(message: "Loading trackers…")
@@ -93,6 +98,8 @@ struct TrackerListView: View {
9398 message: error,
9499 retryAction: { await viewModel.loadTrackers() }
95100 )
101 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
102 ContentUnavailableView.search(text: viewModel.searchText)
96103 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
97104 ContentUnavailableView(
98105 "No Trackers",
Hutch/Views/Tickets/TrackerListViewModel.swift +11
@@ -22,6 +22,7 @@ final class TrackerListViewModel {
2222 private(set) var isLoadingMore = false
2323 private(set) var isCreatingTracker = false
2424 var error: String?
25 var searchText = ""
2526
2627 private var cursor: String?
2728 private var hasMore = true
@@ -31,6 +32,16 @@ final class TrackerListViewModel {
3132 self.client = client
3233 }
3334
35 var filteredTrackers: [TrackerSummary] {
36 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
37 guard !q.isEmpty else { return trackers }
38 return trackers.filter {
39 $0.name.lowercased().contains(q) ||
40 ($0.description?.lowercased().contains(q) == true) ||
41 $0.owner.canonicalName.lowercased().contains(q)
42 }
43 }
44
3445 // MARK: - Query
3546
3647 private static let query = """
HutchTests/BuildListViewModelTests.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct BuildListViewModelTests {
6
7 @Test
8 func filteredJobsReturnsAllWhenSearchTextIsEmpty() {
9 let jobs = [makeJob(id: 1, tags: ["ci"]), makeJob(id: 2, tags: [])]
10 let filtered = filterJobs(jobs, query: "")
11
12 #expect(filtered.count == 2)
13 }
14
15 @Test
16 func filteredJobsMatchesByTag() {
17 let jobs = [makeJob(id: 1, tags: ["ci", "deploy"]), makeJob(id: 2, tags: ["lint"])]
18 let filtered = filterJobs(jobs, query: "deploy")
19
20 #expect(filtered.map(\.id) == [1])
21 }
22
23 @Test
24 func filteredJobsMatchesByJobId() {
25 let jobs = [makeJob(id: 42, tags: []), makeJob(id: 99, tags: [])]
26 let filtered = filterJobs(jobs, query: "42")
27
28 #expect(filtered.map(\.id) == [42])
29 }
30
31 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
32 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
33 guard !q.isEmpty else { return jobs }
34 return jobs.filter {
35 String($0.id).contains(q) ||
36 $0.tags.contains { $0.lowercased().contains(q) } ||
37 ($0.note?.lowercased().contains(q) == true) ||
38 ($0.image?.lowercased().contains(q) == true)
39 }
40 }
41
42 private func makeJob(id: Int, tags: [String]) -> JobSummary {
43 JobSummary(
44 id: id,
45 created: Date(),
46 updated: Date(),
47 status: .success,
48 note: nil,
49 tags: tags,
50 visibility: nil,
51 image: nil,
52 tasks: []
53 )
54 }
55}
HutchTests/InboxViewModelTests.swift +52
@@ -113,6 +113,58 @@ struct InboxViewModelTests {
113113 #expect(first.id != second.id)
114114 }
115115
116 @Test
117 func mailingListThreadFilterMatchesSubjectAndSender() {
118 let baseList = InboxMailingListReference(
119 id: 1,
120 rid: "list",
121 name: "hutch-devel",
122 owner: Entity(canonicalName: "~owner")
123 )
124 let threads = [
125 InboxThreadSummary(
126 rootEmailID: 10,
127 rootMessageID: "message-1",
128 threadRootEmailIDs: [10],
129 threadRootMessageIDs: ["message-1"],
130 listID: baseList.id,
131 listRID: baseList.rid,
132 listName: baseList.name,
133 listOwner: baseList.owner,
134 subject: "Re: [PATCH] add search",
135 latestSender: Entity(canonicalName: "~alice"),
136 lastActivityAt: Date(timeIntervalSince1970: 100),
137 messageCount: 1,
138 repo: "hutch",
139 containsPatch: true,
140 isUnread: true
141 ),
142 InboxThreadSummary(
143 rootEmailID: 11,
144 rootMessageID: "message-2",
145 threadRootEmailIDs: [11],
146 threadRootMessageIDs: ["message-2"],
147 listID: baseList.id,
148 listRID: baseList.rid,
149 listName: baseList.name,
150 listOwner: baseList.owner,
151 subject: "Release planning",
152 latestSender: Entity(canonicalName: "~bob"),
153 lastActivityAt: Date(timeIntervalSince1970: 200),
154 messageCount: 2,
155 repo: "hutch",
156 containsPatch: false,
157 isUnread: true
158 )
159 ]
160
161 let subjectMatches = MailingListDetailViewModel.filterThreads(threads, matching: "search")
162 let senderMatches = MailingListDetailViewModel.filterThreads(threads, matching: "~bob")
163
164 #expect(subjectMatches.map(\.rootEmailID) == [10])
165 #expect(senderMatches.map(\.rootEmailID) == [11])
166 }
167
116168 @Test
117169 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
118170 let body = """
HutchTests/TrackerListViewModelTests.swift +58
@@ -13,4 +13,62 @@ struct TrackerListViewModelTests {
1313
1414 #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
1515 }
16
17 @Test
18 func filteredTrackersReturnsAllWhenSearchTextIsEmpty() {
19 let trackers = [
20 makeTracker(id: 1, name: "bugs", description: "Bug tracker", owner: "~owner"),
21 makeTracker(id: 2, name: "ideas", description: nil, owner: "~team")
22 ]
23
24 let filtered = filterTrackers(trackers, query: "")
25
26 #expect(filtered.count == 2)
27 }
28
29 @Test
30 func filteredTrackersMatchesDescription() {
31 let trackers = [
32 makeTracker(id: 1, name: "bugs", description: "Production incidents", owner: "~owner"),
33 makeTracker(id: 2, name: "ideas", description: "Feature requests", owner: "~team")
34 ]
35
36 let filtered = filterTrackers(trackers, query: "incident")
37
38 #expect(filtered.map(\.id) == [1])
39 }
40
41 @Test
42 func filteredTrackersMatchesOwner() {
43 let trackers = [
44 makeTracker(id: 1, name: "bugs", description: nil, owner: "~owner"),
45 makeTracker(id: 2, name: "ideas", description: nil, owner: "~team")
46 ]
47
48 let filtered = filterTrackers(trackers, query: "~team")
49
50 #expect(filtered.map(\.id) == [2])
51 }
52
53 private func filterTrackers(_ trackers: [TrackerSummary], query: String) -> [TrackerSummary] {
54 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
55 guard !q.isEmpty else { return trackers }
56 return trackers.filter {
57 $0.name.lowercased().contains(q) ||
58 ($0.description?.lowercased().contains(q) == true) ||
59 $0.owner.canonicalName.lowercased().contains(q)
60 }
61 }
62
63 private func makeTracker(id: Int, name: String, description: String?, owner: String) -> TrackerSummary {
64 TrackerSummary(
65 id: id,
66 rid: "rid-\(id)",
67 name: name,
68 description: description,
69 visibility: .public,
70 updated: Date(),
71 owner: Entity(canonicalName: owner)
72 )
73 }
1674}