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 {
64 @Bindable var vm = viewModel 64 @Bindable var vm = viewModel
65 65
66 List { 66 List {
67 ForEach(viewModel.jobs) { job in 67 ForEach(viewModel.filteredJobs) { job in
68 NavigationLink(value: job) { 68 NavigationLink(value: job) {
69 BuildRowView(job: job) 69 BuildRowView(job: job)
70 } 70 }
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 }
71 .task { 83 .task {
72 await viewModel.loadMoreIfNeeded(currentItem: job) 84 await viewModel.loadMoreIfNeeded(currentItem: job)
73 } 85 }
@@ -83,6 +95,11 @@ struct BuildListView: View {
83 } 95 }
84 } 96 }
85 .listStyle(.plain) 97 .listStyle(.plain)
98 .searchable(
99 text: $vm.searchText,
100 placement: .navigationBarDrawer(displayMode: .always),
101 prompt: "Search builds"
102 )
86 .overlay { 103 .overlay {
87 if viewModel.isLoading, viewModel.jobs.isEmpty { 104 if viewModel.isLoading, viewModel.jobs.isEmpty {
88 SRHTLoadingStateView(message: "Loading builds…") 105 SRHTLoadingStateView(message: "Loading builds…")
@@ -92,6 +109,8 @@ struct BuildListView: View {
92 message: error, 109 message: error,
93 retryAction: { await viewModel.loadJobs() } 110 retryAction: { await viewModel.loadJobs() }
94 ) 111 )
112 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
113 ContentUnavailableView.search(text: viewModel.searchText)
95 } else if viewModel.jobs.isEmpty, viewModel.error == nil { 114 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
96 ContentUnavailableView( 115 ContentUnavailableView(
97 "No Builds", 116 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +18
@@ -31,6 +31,7 @@ final class BuildListViewModel {
31 private(set) var isRefreshing = false 31 private(set) var isRefreshing = false
32 private(set) var isSubmitting = false 32 private(set) var isSubmitting = false
33 var error: String? 33 var error: String?
34 var searchText = ""
34 35
35 private var cursor: String? 36 private var cursor: String?
36 private var hasMore = true 37 private var hasMore = true
@@ -42,6 +43,17 @@ final class BuildListViewModel {
42 self.client = client 43 self.client = client
43 } 44 }
44 45
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
45 // MARK: - Query 57 // MARK: - Query
46 58
47 private static let query = """ 59 private static let query = """
@@ -71,6 +83,12 @@ final class BuildListViewModel {
71 } 83 }
72 """ 84 """
73 85
86 private static let cancelMutation = """
87 mutation cancel($id: Int!) {
88 cancel(jobId: $id) { id }
89 }
90 """
91
74 // MARK: - Public API 92 // MARK: - Public API
75 93
76 /// Fetch the first page of jobs. Shows cached data instantly if available, 94 /// 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 {
30 @Bindable var vm = viewModel 30 @Bindable var vm = viewModel
31 31
32 List { 32 List {
33 ForEach(viewModel.threads) { thread in 33 ForEach(viewModel.filteredThreads) { thread in
34 Button { 34 Button {
35 selectThread(thread) 35 selectThread(thread)
36 } label: { 36 } label: {
@@ -45,6 +45,11 @@ struct InboxView: View {
45 } 45 }
46 } 46 }
47 } 47 }
48 .searchable(
49 text: $vm.searchText,
50 placement: .navigationBarDrawer(displayMode: .always),
51 prompt: "Search inbox"
52 )
48 .listStyle(.plain) 53 .listStyle(.plain)
49 .overlay { 54 .overlay {
50 if viewModel.isLoading, viewModel.threads.isEmpty { 55 if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -55,6 +60,8 @@ struct InboxView: View {
55 message: error, 60 message: error,
56 retryAction: { await viewModel.loadThreads() } 61 retryAction: { await viewModel.loadThreads() }
57 ) 62 )
63 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
64 ContentUnavailableView.search(text: viewModel.searchText)
58 } else if viewModel.threads.isEmpty, viewModel.error == nil { 65 } else if viewModel.threads.isEmpty, viewModel.error == nil {
59 ContentUnavailableView( 66 ContentUnavailableView(
60 "Inbox Zero", 67 "Inbox Zero",
Hutch/Views/Inbox/InboxViewModel.swift +11
@@ -62,6 +62,7 @@ final class InboxViewModel {
62 private(set) var threads: [InboxThreadSummary] = [] 62 private(set) var threads: [InboxThreadSummary] = []
63 private(set) var isLoading = false 63 private(set) var isLoading = false
64 var error: String? 64 var error: String?
65 var searchText = ""
65 66
66 private let client: SRHTClient 67 private let client: SRHTClient
67 private let listThreadFetchLimit = 10 68 private let listThreadFetchLimit = 10
@@ -164,6 +165,16 @@ final class InboxViewModel {
164 threads.first(where: { $0.id == id }) 165 threads.first(where: { $0.id == id })
165 } 166 }
166 167
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
167 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { 178 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
168 var subscriptions: [InboxActivitySubscription] = [] 179 var subscriptions: [InboxActivitySubscription] = []
169 var cursor: String? 180 var cursor: String?
Hutch/Views/Lists/MailingListListView.swift +18 −1
@@ -6,6 +6,7 @@ final class MailingListListViewModel {
6 private(set) var mailingLists: [InboxMailingListReference] = [] 6 private(set) var mailingLists: [InboxMailingListReference] = []
7 private(set) var isLoading = false 7 private(set) var isLoading = false
8 var error: String? 8 var error: String?
9 var searchText = ""
9 10
10 private let client: SRHTClient 11 private let client: SRHTClient
11 12
@@ -31,6 +32,15 @@ final class MailingListListViewModel {
31 self.client = client 32 self.client = client
32 } 33 }
33 34
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
34 func loadMailingLists() async { 44 func loadMailingLists() async {
35 guard !isLoading else { return } 45 guard !isLoading else { return }
36 isLoading = true 46 isLoading = true
@@ -120,7 +130,7 @@ struct MailingListListView: View {
120 @Bindable var vm = viewModel 130 @Bindable var vm = viewModel
121 131
122 List { 132 List {
123 ForEach(viewModel.mailingLists, id: \.rid) { mailingList in 133 ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
124 NavigationLink(value: MoreRoute.mailingList(mailingList)) { 134 NavigationLink(value: MoreRoute.mailingList(mailingList)) {
125 VStack(alignment: .leading, spacing: 4) { 135 VStack(alignment: .leading, spacing: 4) {
126 Text(mailingList.name) 136 Text(mailingList.name)
@@ -134,6 +144,11 @@ struct MailingListListView: View {
134 } 144 }
135 } 145 }
136 .listStyle(.plain) 146 .listStyle(.plain)
147 .searchable(
148 text: $vm.searchText,
149 placement: .navigationBarDrawer(displayMode: .always),
150 prompt: "Search lists"
151 )
137 .overlay { 152 .overlay {
138 if viewModel.isLoading, viewModel.mailingLists.isEmpty { 153 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
139 SRHTLoadingStateView(message: "Loading mailing lists…") 154 SRHTLoadingStateView(message: "Loading mailing lists…")
@@ -143,6 +158,8 @@ struct MailingListListView: View {
143 message: error, 158 message: error,
144 retryAction: { await viewModel.loadMailingLists() } 159 retryAction: { await viewModel.loadMailingLists() }
145 ) 160 )
161 } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
162 ContentUnavailableView.search(text: viewModel.searchText)
146 } else if viewModel.mailingLists.isEmpty { 163 } else if viewModel.mailingLists.isEmpty {
147 ContentUnavailableView( 164 ContentUnavailableView(
148 "No Mailing Lists", 165 "No Mailing Lists",
Hutch/Views/Pastes/PasteListView.swift +7
@@ -87,6 +87,11 @@ struct PasteListView: View {
87 } 87 }
88 } 88 }
89 .listStyle(.plain) 89 .listStyle(.plain)
90 .searchable(
91 text: $vm.searchText,
92 placement: .navigationBarDrawer(displayMode: .always),
93 prompt: "Search pastes"
94 )
90 .overlay { 95 .overlay {
91 if viewModel.isLoading, viewModel.pastes.isEmpty { 96 if viewModel.isLoading, viewModel.pastes.isEmpty {
92 SRHTLoadingStateView(message: "Loading pastes…") 97 SRHTLoadingStateView(message: "Loading pastes…")
@@ -96,6 +101,8 @@ struct PasteListView: View {
96 message: error, 101 message: error,
97 retryAction: { await viewModel.loadPastes() } 102 retryAction: { await viewModel.loadPastes() }
98 ) 103 )
104 } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
105 ContentUnavailableView.search(text: viewModel.searchText)
99 } else if viewModel.pastes.isEmpty { 106 } else if viewModel.pastes.isEmpty {
100 ContentUnavailableView( 107 ContentUnavailableView(
101 "No Pastes", 108 "No Pastes",
Hutch/Views/Pastes/PasteListViewModel.swift +12
@@ -9,6 +9,7 @@ final class PasteListViewModel {
9 private(set) var isRefreshing = false 9 private(set) var isRefreshing = false
10 private(set) var isCreatingPaste = false 10 private(set) var isCreatingPaste = false
11 var error: String? 11 var error: String?
12 var searchText = ""
12 13
13 private var cursor: String? 14 private var cursor: String?
14 private var hasMore = true 15 private var hasMore = true
@@ -18,6 +19,17 @@ final class PasteListViewModel {
18 self.service = service 19 self.service = service
19 } 20 }
20 21
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
21 func loadPastes() async { 33 func loadPastes() async {
22 if pastes.isEmpty, let cached = service.loadCachedPastes() { 34 if pastes.isEmpty, let cached = service.loadCachedPastes() {
23 pastes = cached.results 35 pastes = cached.results
Hutch/Views/Projects/ProjectMailingListView.swift +37 −1
@@ -32,6 +32,7 @@ final class MailingListDetailViewModel {
32 private(set) var threads: [InboxThreadSummary] = [] 32 private(set) var threads: [InboxThreadSummary] = []
33 private(set) var isLoading = false 33 private(set) var isLoading = false
34 var error: String? 34 var error: String?
35 var searchText = ""
35 36
36 private let mailingList: InboxMailingListReference 37 private let mailingList: InboxMailingListReference
37 private let client: SRHTClient 38 private let client: SRHTClient
@@ -61,6 +62,10 @@ final class MailingListDetailViewModel {
61 self.client = client 62 self.client = client
62 } 63 }
63 64
65 var filteredThreads: [InboxThreadSummary] {
66 Self.filterThreads(threads, matching: searchText)
67 }
68
64 func loadThreads() async { 69 func loadThreads() async {
65 guard !isLoading else { return } 70 guard !isLoading else { return }
66 isLoading = true 71 isLoading = true
@@ -187,6 +192,30 @@ final class MailingListDetailViewModel {
187 return lhs.lastActivityAt > rhs.lastActivityAt 192 return lhs.lastActivityAt > rhs.lastActivityAt
188 } 193 }
189 } 194 }
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 }
190} 219}
191 220
192struct MailingListDetailView: View { 221struct MailingListDetailView: View {
@@ -225,7 +254,7 @@ struct MailingListDetailView: View {
225 @Bindable var vm = viewModel 254 @Bindable var vm = viewModel
226 255
227 List { 256 List {
228 ForEach(viewModel.threads) { thread in 257 ForEach(viewModel.filteredThreads) { thread in
229 NavigationLink(value: MoreRoute.thread(thread)) { 258 NavigationLink(value: MoreRoute.thread(thread)) {
230 InboxThreadRow(thread: thread) 259 InboxThreadRow(thread: thread)
231 } 260 }
@@ -249,6 +278,11 @@ struct MailingListDetailView: View {
249 } 278 }
250 } 279 }
251 .listStyle(.plain) 280 .listStyle(.plain)
281 .searchable(
282 text: $vm.searchText,
283 placement: .navigationBarDrawer(displayMode: .always),
284 prompt: "Search messages"
285 )
252 .overlay { 286 .overlay {
253 if viewModel.isLoading, viewModel.threads.isEmpty { 287 if viewModel.isLoading, viewModel.threads.isEmpty {
254 SRHTLoadingStateView(message: "Loading mailing list…") 288 SRHTLoadingStateView(message: "Loading mailing list…")
@@ -258,6 +292,8 @@ struct MailingListDetailView: View {
258 message: error, 292 message: error,
259 retryAction: { await viewModel.loadThreads() } 293 retryAction: { await viewModel.loadThreads() }
260 ) 294 )
295 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
296 ContentUnavailableView.search(text: viewModel.searchText)
261 } else if viewModel.threads.isEmpty { 297 } else if viewModel.threads.isEmpty {
262 ContentUnavailableView( 298 ContentUnavailableView(
263 "No Threads", 299 "No Threads",
Hutch/Views/Tickets/TicketListView.swift +8
@@ -108,6 +108,11 @@ struct TicketListView: View {
108 } 108 }
109 } 109 }
110 .listStyle(.plain) 110 .listStyle(.plain)
111 .searchable(
112 text: $vm.searchText,
113 placement: .navigationBarDrawer(displayMode: .always),
114 prompt: "Search tickets"
115 )
111 .overlay { 116 .overlay {
112 if viewModel.isLoading, viewModel.tickets.isEmpty { 117 if viewModel.isLoading, viewModel.tickets.isEmpty {
113 SRHTLoadingStateView(message: "Loading tickets…") 118 SRHTLoadingStateView(message: "Loading tickets…")
@@ -117,6 +122,9 @@ struct TicketListView: View {
117 message: error, 122 message: error,
118 retryAction: { await viewModel.loadTickets() } 123 retryAction: { await viewModel.loadTickets() }
119 ) 124 )
125 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
126 viewModel.filteredTickets.isEmpty {
127 ContentUnavailableView.search(text: viewModel.searchText)
120 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { 128 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
121 ContentUnavailableView( 129 ContentUnavailableView(
122 "No Tickets", 130 "No Tickets",
Hutch/Views/Tickets/TicketListViewModel.swift +75 −5
@@ -35,23 +35,40 @@ final class TicketListViewModel {
35 let ownerUsername: String 35 let ownerUsername: String
36 let trackerName: String 36 let trackerName: String
37 let trackerId: Int 37 let trackerId: Int
38 let trackerRid: String
38 39
39 private(set) var tickets: [TicketSummary] = [] 40 private(set) var tickets: [TicketSummary] = []
40 private(set) var isLoading = false 41 private(set) var isLoading = false
41 private(set) var isLoadingMore = false 42 private(set) var isLoadingMore = false
42 private(set) var isCreatingTicket = false 43 private(set) var isCreatingTicket = false
44 private(set) var isPerformingAction = false
45 private(set) var trackerLabels: [TicketLabel] = []
43 var error: String? 46 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 = ""
45 53
46 private var cursor: String? 54 private var cursor: String?
47 private var hasMore = true 55 private var hasMore = true
48 private let client: SRHTClient 56 private let client: SRHTClient
49 57
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) {
51 self.ownerUsername = ownerUsername 63 self.ownerUsername = ownerUsername
52 self.trackerName = trackerName 64 self.trackerName = trackerName
53 self.trackerId = trackerId 65 self.trackerId = trackerId
66 self.trackerRid = trackerRid
54 self.client = client 67 self.client = client
68 if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
69 let restored = TicketFilter(rawValue: raw) {
70 self.filter = restored
71 }
55 } 72 }
56 73
57 // MARK: - Query 74 // MARK: - Query
@@ -93,17 +110,70 @@ final class TicketListViewModel {
93 } 110 }
94 """ 111 """
95 112
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
96 // MARK: - Computed 157 // MARK: - Computed
97 158
98 /// Tickets filtered by the selected status filter. 159 /// Tickets filtered by the selected status filter.
99 var filteredTickets: [TicketSummary] { 160 var filteredTickets: [TicketSummary] {
161 let statusFiltered: [TicketSummary]
100 switch filter { 162 switch filter {
101 case .open: 163 case .open:
102 tickets.filter { $0.status.isOpen } 164 statusFiltered = tickets.filter { $0.status.isOpen }
103 case .resolved: 165 case .resolved:
104 tickets.filter { !$0.status.isOpen } 166 statusFiltered = tickets.filter { !$0.status.isOpen }
105 case .all: 167 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) }
107 } 177 }
108 } 178 }
109 179
Hutch/Views/Tickets/TrackerListView.swift +8 −1
@@ -65,7 +65,7 @@ struct TrackerListView: View {
65 @Bindable var vm = viewModel 65 @Bindable var vm = viewModel
66 66
67 List { 67 List {
68 ForEach(viewModel.trackers) { tracker in 68 ForEach(viewModel.filteredTrackers) { tracker in
69 NavigationLink(value: tracker) { 69 NavigationLink(value: tracker) {
70 TrackerRowView(tracker: tracker) 70 TrackerRowView(tracker: tracker)
71 } 71 }
@@ -84,6 +84,11 @@ struct TrackerListView: View {
84 } 84 }
85 } 85 }
86 .listStyle(.plain) 86 .listStyle(.plain)
87 .searchable(
88 text: $vm.searchText,
89 placement: .navigationBarDrawer(displayMode: .always),
90 prompt: "Search trackers"
91 )
87 .overlay { 92 .overlay {
88 if viewModel.isLoading, viewModel.trackers.isEmpty { 93 if viewModel.isLoading, viewModel.trackers.isEmpty {
89 SRHTLoadingStateView(message: "Loading trackers…") 94 SRHTLoadingStateView(message: "Loading trackers…")
@@ -93,6 +98,8 @@ struct TrackerListView: View {
93 message: error, 98 message: error,
94 retryAction: { await viewModel.loadTrackers() } 99 retryAction: { await viewModel.loadTrackers() }
95 ) 100 )
101 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
102 ContentUnavailableView.search(text: viewModel.searchText)
96 } else if viewModel.trackers.isEmpty, viewModel.error == nil { 103 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
97 ContentUnavailableView( 104 ContentUnavailableView(
98 "No Trackers", 105 "No Trackers",
Hutch/Views/Tickets/TrackerListViewModel.swift +11
@@ -22,6 +22,7 @@ final class TrackerListViewModel {
22 private(set) var isLoadingMore = false 22 private(set) var isLoadingMore = false
23 private(set) var isCreatingTracker = false 23 private(set) var isCreatingTracker = false
24 var error: String? 24 var error: String?
25 var searchText = ""
25 26
26 private var cursor: String? 27 private var cursor: String?
27 private var hasMore = true 28 private var hasMore = true
@@ -31,6 +32,16 @@ final class TrackerListViewModel {
31 self.client = client 32 self.client = client
32 } 33 }
33 34
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
34 // MARK: - Query 45 // MARK: - Query
35 46
36 private static let query = """ 47 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 {
113 #expect(first.id != second.id) 113 #expect(first.id != second.id)
114 } 114 }
115 115
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
116 @Test 168 @Test
117 func segmentsPatchBodyAndTreatsSignatureAsPlainText() { 169 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
118 let body = """ 170 let body = """
HutchTests/TrackerListViewModelTests.swift +58
@@ -13,4 +13,62 @@ struct TrackerListViewModelTests {
13 13
14 #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists") 14 #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
15 } 15 }
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 }
16} 74}