krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.0: Hutch/Views/Tickets/TicketListViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackerTicketsResponse: Decodable, Sendable {
6 let tracker: TrackerTicketsWrapper
7}
8
9private struct TrackerTicketsWrapper: Decodable, Sendable {
10 let tickets: TicketsPage
11}
12
13private struct TicketsPage: Decodable, Sendable {
14 let results: [TicketSummary]
15 let cursor: String?
16}
17
18private struct AssignmentMutationResponse: Decodable, Sendable {
19 struct EventRef: Decodable, Sendable {
20 let id: Int
21 }
22
23 let assignUser: EventRef?
24 let unassignUser: EventRef?
25}
26
27private struct LabelMutationResponse: Decodable, Sendable {
28 struct EventRef: Decodable, Sendable {
29 let id: Int
30 }
31
32 let labelTicket: EventRef?
33 let unlabelTicket: EventRef?
34}
35
36private struct TrackerLabelsResponse: Decodable, Sendable {
37 let tracker: TrackerLabelsWrapper
38}
39
40private struct TrackerLabelsWrapper: Decodable, Sendable {
41 let labels: LabelsPage
42}
43
44private struct LabelsPage: Decodable, Sendable {
45 let results: [TicketLabel]
46}
47
48private struct UpdateStatusResponse: Decodable, Sendable {
49 let updateTicketStatus: MutationEventRef
50}
51
52private struct MutationEventRef: Decodable, Sendable {
53 let eventType: String
54}
55
56private struct TicketListUserLookupResponse: Decodable, Sendable {
57 let user: TicketListUserIDPayload?
58}
59
60private struct TicketListUserIDPayload: Decodable, Sendable {
61 let id: Int
62}
63
64// MARK: - Filter
65
66enum TicketFilter: String, CaseIterable, Codable, Sendable {
67 case open = "Open"
68 case resolved = "Resolved"
69 case all = "All"
70}
71
72// MARK: - View Model
73
74@Observable
75@MainActor
76final class TicketListViewModel {
77 private static func searchHistoryScopeID(for trackerRid: String) -> String {
78 "tickets.\(trackerRid)"
79 }
80
81 let ownerUsername: String
82 let trackerName: String
83 let trackerId: Int
84 let trackerRid: String
85
86 private(set) var tickets: [TicketSummary] = []
87 private(set) var isLoading = false
88 private(set) var isLoadingMore = false
89 private(set) var isCreatingTicket = false
90 private(set) var isPerformingAction = false
91 private(set) var trackerLabels: [TicketLabel] = []
92 private(set) var recentSearches: [ScopedSearchHistoryEntry]
93 private(set) var savedFilters: [SavedTicketFilter]
94 private(set) var isSelectionMode = false
95 private(set) var selectedTicketIDs: Set<Int> = []
96 var error: String?
97 var filter: TicketFilter = .open {
98 didSet {
99 persistFilterState()
100 }
101 }
102 var selectedLabelIDs: Set<Int> = [] {
103 didSet {
104 persistFilterState()
105 }
106 }
107 var searchText = ""
108 private(set) var activeSavedFilterID: SavedTicketFilter.ID?
109
110 private var cursor: String?
111 private var hasMore = true
112 private let client: SRHTClient
113 private let defaults: UserDefaults
114
115 init(
116 ownerUsername: String,
117 trackerName: String,
118 trackerId: Int,
119 trackerRid: String,
120 client: SRHTClient,
121 defaults: UserDefaults = .standard
122 ) {
123 self.ownerUsername = ownerUsername
124 self.trackerName = trackerName
125 self.trackerId = trackerId
126 self.trackerRid = trackerRid
127 self.client = client
128 self.defaults = defaults
129
130 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
131 let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
132 self.filter = restoredState.status
133 self.selectedLabelIDs = Set(restoredState.labelIDs)
134 self.savedFilters = savedFilters
135 self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id
136 self.recentSearches = ScopedSearchHistoryStore.load(
137 scopeID: Self.searchHistoryScopeID(for: trackerRid),
138 defaults: defaults
139 )
140 }
141
142 // MARK: - Query
143
144 private static let query = """
145 query tickets($rid: ID!, $cursor: Cursor) {
146 tracker(rid: $rid) {
147 tickets(cursor: $cursor) {
148 results {
149 id
150 title: subject
151 status
152 resolution
153 created
154 submitter { canonicalName }
155 labels { id name backgroundColor foregroundColor }
156 assignees { canonicalName }
157 }
158 cursor
159 }
160 }
161 }
162 """
163
164 private static let submitTicketMutation = """
165 mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
166 submitTicket(trackerId: $trackerId, input: $input) {
167 id
168 title: subject
169 status
170 resolution
171 created
172 submitter { canonicalName }
173 labels { id name backgroundColor foregroundColor }
174 assignees { canonicalName }
175 }
176 }
177 """
178
179 private static let updateStatusMutation = """
180 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
181 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
182 eventType: __typename
183 }
184 }
185 """
186
187 private static let assignUserMutation = """
188 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
189 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
190 }
191 """
192
193 private static let unassignUserMutation = """
194 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
195 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
196 }
197 """
198
199 private static let labelTicketMutation = """
200 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
201 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
202 }
203 """
204
205 private static let unlabelTicketMutation = """
206 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
207 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
208 }
209 """
210
211 private static let trackerLabelsQuery = """
212 query trackerLabels($rid: ID!) {
213 tracker(rid: $rid) {
214 labels {
215 results { id name backgroundColor foregroundColor }
216 }
217 }
218 }
219 """
220
221 private static let userLookupQuery = """
222 query userLookup($username: String!) {
223 user(username: $username) { id }
224 }
225 """
226
227 // MARK: - Computed
228
229 var currentFilterState: TicketListFilterState {
230 TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
231 }
232
233 var availableLabels: [TicketLabel] {
234 let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
235 let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
236 partialResult[label.id] = label
237 }
238 return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
239 }
240
241 var selectedLabels: [TicketLabel] {
242 availableLabels.filter { selectedLabelIDs.contains($0.id) }
243 }
244
245 var selectedTickets: [TicketSummary] {
246 tickets.filter { selectedTicketIDs.contains($0.id) }
247 }
248
249 var selectedTicketCount: Int {
250 selectedTicketIDs.count
251 }
252
253 var suggestedSavedFilterName: String {
254 let labelNames = selectedLabels.map(\.name).sorted()
255 var components: [String] = []
256
257 if filter != .open || !labelNames.isEmpty {
258 components.append(filter.rawValue)
259 }
260 if !labelNames.isEmpty {
261 components.append(labelNames.joined(separator: ", "))
262 }
263
264 return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ")
265 }
266
267 var hasCustomFilterSelection: Bool {
268 !currentFilterState.isDefault
269 }
270
271 /// Tickets filtered by the selected status and label filters.
272 var filteredTickets: [TicketSummary] {
273 Self.filterTickets(tickets, state: currentFilterState, query: searchText)
274 }
275
276 // MARK: - Public API
277
278 func loadTickets() async {
279 isLoading = true
280 error = nil
281 cursor = nil
282 hasMore = true
283
284 do {
285 let page = try await fetchPage(cursor: nil)
286 tickets = page.results
287 cursor = page.cursor
288 hasMore = page.cursor != nil
289 reconcileSelectionWithLoadedTickets()
290 } catch {
291 self.error = error.userFacingMessage
292 }
293
294 isLoading = false
295 }
296
297 func loadMoreIfNeeded(currentItem: TicketSummary) async {
298 guard let last = tickets.last,
299 last.id == currentItem.id,
300 hasMore,
301 !isLoadingMore else {
302 return
303 }
304
305 isLoadingMore = true
306
307 do {
308 let page = try await fetchPage(cursor: cursor)
309 tickets.append(contentsOf: page.results)
310 cursor = page.cursor
311 hasMore = page.cursor != nil
312 reconcileSelectionWithLoadedTickets()
313 } catch {
314 self.error = error.userFacingMessage
315 }
316
317 isLoadingMore = false
318 }
319
320 func createTicket(subject: String, body: String) async -> TicketSummary? {
321 guard !isCreatingTicket else { return nil }
322
323 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
324 guard !trimmedSubject.isEmpty else {
325 error = "Enter a ticket title."
326 return nil
327 }
328
329 isCreatingTicket = true
330 error = nil
331 defer { isCreatingTicket = false }
332
333 var input: [String: any Sendable] = [
334 "subject": trimmedSubject
335 ]
336 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
337 if !trimmedBody.isEmpty {
338 input["body"] = trimmedBody
339 }
340 let variables: [String: any Sendable] = [
341 "trackerId": trackerId,
342 "input": input
343 ]
344
345 do {
346 let result = try await client.execute(
347 service: .todo,
348 query: Self.submitTicketMutation,
349 variables: variables,
350 responseType: SubmitTicketResponse.self
351 )
352 let ticket = result.submitTicket
353 tickets.insert(ticket, at: 0)
354 return ticket
355 } catch {
356 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
357 return nil
358 }
359 }
360
361 func resolveTicket(_ ticket: TicketSummary) async {
362 let input: [String: any Sendable] = [
363 "status": TicketStatus.resolved.rawValue,
364 "resolution": TicketResolution.fixed.rawValue
365 ]
366 await performStatusUpdate(ticket: ticket, input: input)
367 }
368
369 func reopenTicket(_ ticket: TicketSummary) async {
370 let input: [String: any Sendable] = [
371 "status": TicketStatus.reported.rawValue
372 ]
373 await performStatusUpdate(ticket: ticket, input: input)
374 }
375
376 func assignToMe(ticket: TicketSummary, user: User) async {
377 guard !isPerformingAction else { return }
378 isPerformingAction = true
379 error = nil
380
381 let original = tickets
382 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
383 let entity = Entity(canonicalName: user.canonicalName)
384 let updated = TicketSummary(
385 id: ticket.id,
386 title: ticket.title,
387 status: ticket.status,
388 resolution: ticket.resolution,
389 created: ticket.created,
390 submitter: ticket.submitter,
391 labels: ticket.labels,
392 assignees: ticket.assignees + [entity]
393 )
394 tickets[index] = updated
395 }
396
397 do {
398 _ = try await client.execute(
399 service: .todo,
400 query: Self.assignUserMutation,
401 variables: [
402 "trackerId": trackerId,
403 "ticketId": ticket.id,
404 "userId": user.id
405 ],
406 responseType: AssignmentMutationResponse.self
407 )
408 } catch {
409 tickets = original
410 self.error = error.userFacingMessage
411 }
412
413 isPerformingAction = false
414 }
415
416 func unassignFromMe(ticket: TicketSummary, user: User) async {
417 guard !isPerformingAction else { return }
418 isPerformingAction = true
419 error = nil
420
421 let original = tickets
422 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
423 let filtered = ticket.assignees.filter { assignee in
424 !Self.matchesAssignee(assignee, user: user)
425 }
426 let updated = TicketSummary(
427 id: ticket.id,
428 title: ticket.title,
429 status: ticket.status,
430 resolution: ticket.resolution,
431 created: ticket.created,
432 submitter: ticket.submitter,
433 labels: ticket.labels,
434 assignees: filtered
435 )
436 tickets[index] = updated
437 }
438
439 do {
440 _ = try await client.execute(
441 service: .todo,
442 query: Self.unassignUserMutation,
443 variables: [
444 "trackerId": trackerId,
445 "ticketId": ticket.id,
446 "userId": user.id
447 ],
448 responseType: AssignmentMutationResponse.self
449 )
450 } catch {
451 tickets = original
452 self.error = error.userFacingMessage
453 }
454
455 isPerformingAction = false
456 }
457
458 func loadTrackerLabels() async {
459 do {
460 let result = try await client.execute(
461 service: .todo,
462 query: Self.trackerLabelsQuery,
463 variables: ["rid": trackerRid],
464 responseType: TrackerLabelsResponse.self
465 )
466 syncTrackerLabels(result.tracker.labels.results)
467 } catch {
468 self.error = error.userFacingMessage
469 }
470 }
471
472 func syncTrackerLabels(_ labels: [TicketLabel]) {
473 trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
474 tickets = Self.synchronizeTickets(tickets, with: trackerLabels)
475 selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels)
476 }
477
478 func toggleLabelSelection(_ label: TicketLabel) {
479 if selectedLabelIDs.contains(label.id) {
480 selectedLabelIDs.remove(label.id)
481 } else {
482 selectedLabelIDs.insert(label.id)
483 }
484 }
485
486 func clearLabelSelection() {
487 selectedLabelIDs = []
488 }
489
490 func setSelectionMode(_ enabled: Bool) {
491 isSelectionMode = enabled
492 if !enabled {
493 clearTicketSelection()
494 }
495 }
496
497 func toggleTicketSelection(_ ticket: TicketSummary) {
498 if selectedTicketIDs.contains(ticket.id) {
499 selectedTicketIDs.remove(ticket.id)
500 } else {
501 selectedTicketIDs.insert(ticket.id)
502 }
503 }
504
505 func selectVisibleTickets(_ tickets: [TicketSummary]) {
506 selectedTicketIDs = Set(tickets.map(\.id))
507 }
508
509 func clearTicketSelection() {
510 selectedTicketIDs = []
511 }
512
513 func resetFilters() {
514 filter = .open
515 selectedLabelIDs = []
516 }
517
518 func recordRecentSearch(_ query: String) {
519 ScopedSearchHistoryStore.record(
520 query: query,
521 scopeID: Self.searchHistoryScopeID(for: trackerRid),
522 defaults: defaults
523 )
524 recentSearches = ScopedSearchHistoryStore.load(
525 scopeID: Self.searchHistoryScopeID(for: trackerRid),
526 defaults: defaults
527 )
528 }
529
530 func clearRecentSearches() {
531 ScopedSearchHistoryStore.clear(
532 scopeID: Self.searchHistoryScopeID(for: trackerRid),
533 defaults: defaults
534 )
535 recentSearches = []
536 }
537
538 func applySavedFilter(_ savedFilter: SavedTicketFilter) {
539 filter = savedFilter.state.status
540 selectedLabelIDs = Set(savedFilter.state.labelIDs)
541 activeSavedFilterID = savedFilter.id
542 }
543
544 func saveCurrentFilter(named name: String) {
545 guard let savedFilter = TicketSavedFilterStore.saveFilter(
546 named: name,
547 state: currentFilterState,
548 for: trackerRid,
549 defaults: defaults
550 ) else {
551 return
552 }
553
554 savedFilters.removeAll {
555 $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
556 }
557 savedFilters.insert(savedFilter, at: 0)
558 activeSavedFilterID = savedFilter.id
559 }
560
561 func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
562 TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
563 savedFilters.removeAll { $0.id == savedFilter.id }
564 if activeSavedFilterID == savedFilter.id {
565 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
566 }
567 }
568
569 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
570 guard !isPerformingAction else { return }
571 isPerformingAction = true
572 error = nil
573
574 let original = tickets
575 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
576 let updated = TicketSummary(
577 id: ticket.id,
578 title: ticket.title,
579 status: ticket.status,
580 resolution: ticket.resolution,
581 created: ticket.created,
582 submitter: ticket.submitter,
583 labels: ticket.labels + [label],
584 assignees: ticket.assignees
585 )
586 tickets[index] = updated
587 }
588
589 do {
590 _ = try await client.execute(
591 service: .todo,
592 query: Self.labelTicketMutation,
593 variables: [
594 "trackerId": trackerId,
595 "ticketId": ticket.id,
596 "labelId": label.id
597 ],
598 responseType: LabelMutationResponse.self
599 )
600 } catch {
601 tickets = original
602 self.error = error.userFacingMessage
603 }
604
605 isPerformingAction = false
606 }
607
608 func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
609 guard !isPerformingAction else { return }
610 isPerformingAction = true
611 error = nil
612
613 let original = tickets
614 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
615 let filtered = ticket.labels.filter { $0.id != label.id }
616 let updated = TicketSummary(
617 id: ticket.id,
618 title: ticket.title,
619 status: ticket.status,
620 resolution: ticket.resolution,
621 created: ticket.created,
622 submitter: ticket.submitter,
623 labels: filtered,
624 assignees: ticket.assignees
625 )
626 tickets[index] = updated
627 }
628
629 do {
630 _ = try await client.execute(
631 service: .todo,
632 query: Self.unlabelTicketMutation,
633 variables: [
634 "trackerId": trackerId,
635 "ticketId": ticket.id,
636 "labelId": label.id
637 ],
638 responseType: LabelMutationResponse.self
639 )
640 } catch {
641 tickets = original
642 self.error = error.userFacingMessage
643 }
644
645 isPerformingAction = false
646 }
647
648 func ticket(withId ticketId: Int) -> TicketSummary? {
649 tickets.first(where: { $0.id == ticketId })
650 }
651
652 func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? {
653 await performBulkAction(
654 kind: .close,
655 prepare: { ticket in
656 guard ticket.status != .resolved else { return .unchanged }
657
658 let input = Self.bulkStatusUpdateInput(resolution: resolution)
659 let updatedTicket = updatedTicket(from: ticket, input: input)
660 return .request(updatedTicket: updatedTicket) {
661 try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input)
662 }
663 }
664 )
665 }
666
667 func assignSelectedTickets(username: String) async -> TicketBulkActionResult? {
668 let normalizedUsername = Self.normalizedUsername(username)
669 guard !normalizedUsername.isEmpty else {
670 error = "Enter a SourceHut username."
671 return nil
672 }
673
674 do {
675 let userResult = try await client.execute(
676 service: .todo,
677 query: Self.userLookupQuery,
678 variables: ["username": normalizedUsername],
679 responseType: TicketListUserLookupResponse.self
680 )
681 guard let userID = userResult.user?.id else {
682 error = "That user couldn’t be found."
683 return nil
684 }
685
686 let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername))
687 return await performBulkAction(
688 kind: .assign,
689 prepare: { ticket in
690 guard !ticket.assignees.contains(where: {
691 Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName
692 }) else {
693 return .unchanged
694 }
695
696 let updatedTicket = TicketSummary(
697 id: ticket.id,
698 title: ticket.title,
699 status: ticket.status,
700 resolution: ticket.resolution,
701 created: ticket.created,
702 submitter: ticket.submitter,
703 labels: ticket.labels,
704 assignees: ticket.assignees + [assignee]
705 )
706
707 return .request(updatedTicket: updatedTicket) {
708 try await self.executeBulkAssign(ticketID: ticket.id, userID: userID)
709 }
710 }
711 )
712 } catch {
713 self.error = error.userFacingMessage
714 return nil
715 }
716 }
717
718 // MARK: - Private
719
720 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
721 guard !isPerformingAction else { return }
722 isPerformingAction = true
723 error = nil
724
725 do {
726 let variables: [String: any Sendable] = [
727 "trackerId": trackerId,
728 "ticketId": ticket.id,
729 "input": input
730 ]
731 let result = try await client.execute(
732 service: .todo,
733 query: Self.updateStatusMutation,
734 variables: variables,
735 responseType: UpdateStatusResponse.self
736 )
737 _ = result.updateTicketStatus
738 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
739 tickets[index] = updatedTicket(from: ticket, input: input)
740 }
741 } catch {
742 self.error = error.userFacingMessage
743 }
744
745 isPerformingAction = false
746 }
747
748 private func fetchPage(cursor: String?) async throws -> TicketsPage {
749 var variables: [String: any Sendable] = ["rid": trackerRid]
750 if let cursor {
751 variables["cursor"] = cursor
752 }
753 let result = try await client.execute(
754 service: .todo,
755 query: Self.query,
756 variables: variables,
757 responseType: TrackerTicketsResponse.self
758 )
759 return result.tracker.tickets
760 }
761
762 private struct SubmitTicketResponse: Decodable, Sendable {
763 let submitTicket: TicketSummary
764 }
765
766 private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
767 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
768 let userCanonical = normalizedCanonicalName(user.canonicalName)
769 if assigneeCanonical == userCanonical {
770 return true
771 }
772 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
773 }
774
775 private static func normalizedCanonicalName(_ value: String) -> String {
776 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
777 if trimmed.hasPrefix("~") {
778 return trimmed
779 }
780 return "~\(trimmed)"
781 }
782
783 private static func normalizedUsername(_ value: String) -> String {
784 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
785 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
786 }
787
788 private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
789 let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
790 let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
791
792 return TicketSummary(
793 id: ticket.id,
794 title: ticket.title,
795 status: updatedStatus,
796 resolution: updatedStatus == .resolved ? updatedResolution : nil,
797 created: ticket.created,
798 submitter: ticket.submitter,
799 labels: ticket.labels,
800 assignees: ticket.assignees
801 )
802 }
803
804 private func persistFilterState() {
805 TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
806 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
807 }
808
809 private func reconcileSelectionWithLoadedTickets() {
810 let loadedTicketIDs = Set(tickets.map(\.id))
811 selectedTicketIDs.formIntersection(loadedTicketIDs)
812 if isSelectionMode, selectedTicketIDs.isEmpty {
813 isSelectionMode = false
814 }
815 }
816
817 private func performBulkAction(
818 kind: TicketBulkActionKind,
819 prepare: (TicketSummary) -> TicketBulkTicketOperation
820 ) async -> TicketBulkActionResult? {
821 guard !isPerformingAction else { return nil }
822
823 let selected = selectedTickets
824 guard !selected.isEmpty else { return nil }
825
826 isPerformingAction = true
827 error = nil
828
829 var updatedCount = 0
830 var unchangedCount = 0
831 var failures: [TicketBulkActionFailure] = []
832 var failedTicketIDs = Set<Int>()
833
834 for ticket in selected {
835 switch prepare(ticket) {
836 case .unchanged:
837 unchangedCount += 1
838 case .request(let updatedTicket, let request):
839 replaceTicket(updatedTicket)
840 do {
841 try await request()
842 updatedCount += 1
843 } catch {
844 replaceTicket(ticket)
845 failedTicketIDs.insert(ticket.id)
846 failures.append(
847 TicketBulkActionFailure(
848 ticketID: ticket.id,
849 message: error.userFacingMessage
850 )
851 )
852 }
853 }
854 }
855
856 isPerformingAction = false
857
858 let result = TicketBulkActionResult(
859 action: kind,
860 totalCount: selected.count,
861 updatedCount: updatedCount,
862 unchangedCount: unchangedCount,
863 failures: failures
864 )
865
866 if failedTicketIDs.isEmpty {
867 clearTicketSelection()
868 isSelectionMode = false
869 } else {
870 selectedTicketIDs = failedTicketIDs
871 isSelectionMode = true
872 }
873
874 return result
875 }
876
877 private func replaceTicket(_ ticket: TicketSummary) {
878 guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return }
879 tickets[index] = ticket
880 }
881
882 private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws {
883 _ = try await client.execute(
884 service: .todo,
885 query: Self.updateStatusMutation,
886 variables: [
887 "trackerId": trackerId,
888 "ticketId": ticketID,
889 "input": input
890 ],
891 responseType: UpdateStatusResponse.self
892 )
893 }
894
895 private func executeBulkAssign(ticketID: Int, userID: Int) async throws {
896 _ = try await client.execute(
897 service: .todo,
898 query: Self.assignUserMutation,
899 variables: [
900 "trackerId": trackerId,
901 "ticketId": ticketID,
902 "userId": userID
903 ],
904 responseType: AssignmentMutationResponse.self
905 )
906 }
907
908 private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
909 [
910 "status": TicketStatus.resolved.rawValue,
911 "resolution": resolution.rawValue
912 ]
913 }
914
915 static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] {
916 let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) })
917
918 return tickets.map { ticket in
919 let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] }
920 return TicketSummary(
921 id: ticket.id,
922 title: ticket.title,
923 status: ticket.status,
924 resolution: ticket.resolution,
925 created: ticket.created,
926 submitter: ticket.submitter,
927 labels: updatedLabels,
928 assignees: ticket.assignees
929 )
930 }
931 }
932
933 static func reconciledSelectedLabelIDs(
934 _ selectedLabelIDs: Set<Int>,
935 availableLabels: [TicketLabel]
936 ) -> Set<Int> {
937 selectedLabelIDs.intersection(Set(availableLabels.map(\.id)))
938 }
939
940 static func filterTickets(
941 _ tickets: [TicketSummary],
942 state: TicketListFilterState,
943 query: String
944 ) -> [TicketSummary] {
945 let statusFiltered: [TicketSummary]
946 switch state.status {
947 case .open:
948 statusFiltered = tickets.filter { $0.status.isOpen }
949 case .resolved:
950 statusFiltered = tickets.filter { !$0.status.isOpen }
951 case .all:
952 statusFiltered = tickets
953 }
954
955 let labelFiltered: [TicketSummary]
956 if state.labelIDs.isEmpty {
957 labelFiltered = statusFiltered
958 } else {
959 let selectedLabelIDs = Set(state.labelIDs)
960 labelFiltered = statusFiltered.filter { ticket in
961 !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
962 }
963 }
964
965 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
966 guard !q.isEmpty else { return labelFiltered }
967 return labelFiltered.filter {
968 String($0.id).contains(q) ||
969 $0.title.lowercased().contains(q) ||
970 $0.submitter.canonicalName.lowercased().contains(q) ||
971 $0.labels.contains { $0.name.lowercased().contains(q) } ||
972 $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
973 $0.status.displayName.lowercased().contains(q)
974 }
975 }
976}
977
978private enum TicketBulkTicketOperation {
979 case unchanged
980 case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void)
981}