diff options
| author | Christian Cleberg <[email protected]> | 2026-03-22 20:42:45 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-22 20:42:45 -0500 |
| commit | 1f8b7a23529462c3c332753d33810b5c9f692bbc (patch) | |
| tree | 17526dae77a7579b270e6b9cca6224c9020f772d | |
| parent | e03791d6c15ee0caac033d1b1b390c5d65a69d57 (diff) | |
| download | hutch-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.swift | 21 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 18 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxView.swift | 9 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxViewModel.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Lists/MailingListListView.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListViewModel.swift | 12 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 38 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 80 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListView.swift | 9 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListViewModel.swift | 11 | ||||
| -rw-r--r-- | HutchTests/BuildListViewModelTests.swift | 55 | ||||
| -rw-r--r-- | HutchTests/InboxViewModelTests.swift | 52 | ||||
| -rw-r--r-- | 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 @@ -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) + ) + } } |
