krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.0: Hutch/Views/Tickets/TicketDetailView.swift · raw
1import SwiftUI
2import WebKit
3
4struct TicketDetailView: View {
5 let ownerUsername: String
6 let trackerName: String
7 let trackerId: Int
8 let trackerRid: String
9 let ticketId: Int
10
11 @Environment(AppState.self) private var appState
12 @Environment(\.colorScheme) private var colorScheme
13 @Environment(\.openURL) private var openURL
14 @Environment(\.dismiss) private var dismiss
15 @State private var viewModel: TicketDetailViewModel?
16
17 // Sheet state
18 @State private var showResolveSheet = false
19 @State private var showAssignSheet = false
20 @State private var showLabelsSheet = false
21 @State private var showEditSheet = false
22 @State private var showDeleteConfirmation = false
23 @State private var isOpeningTracker = false
24
25 // Comment composer mode
26 @State private var commentMode: CommentMode = .write
27
28 private var isOwnedByCurrentUser: Bool {
29 guard let currentUser = appState.currentUser else { return false }
30 return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername)
31 }
32
33 private enum CommentMode: String, CaseIterable {
34 case write = "Write"
35 case preview = "Preview"
36 }
37
38 var body: some View {
39 Group {
40 if let viewModel {
41 detailContent(viewModel)
42 } else {
43 SRHTLoadingStateView(message: "Loading ticket…")
44 }
45 }
46 .navigationTitle("#\(ticketId)")
47 .navigationBarTitleDisplayMode(.inline)
48 .toolbar {
49 ToolbarItemGroup(placement: .topBarTrailing) {
50 SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
51 Image(systemName: "square.and.arrow.up")
52 }
53
54 if let viewModel, viewModel.ticket != nil {
55 actionsMenu(viewModel)
56 }
57 }
58 }
59 .task {
60 if viewModel == nil {
61 let vm = TicketDetailViewModel(
62 ownerUsername: ownerUsername,
63 trackerName: trackerName,
64 trackerId: trackerId,
65 trackerRid: trackerRid,
66 ticketId: ticketId,
67 client: appState.client
68 )
69 viewModel = vm
70 await reloadDetail(vm)
71 }
72 }
73 }
74
75 // MARK: - Actions Menu
76
77 @ViewBuilder
78 private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
79 Menu {
80 if let ticketURL = SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) {
81 Button {
82 openURL(ticketURL)
83 } label: {
84 SwiftUI.Label("Open in Browser", systemImage: "safari")
85 }
86
87 Button {
88 appState.copyToPasteboard(ticketURL.absoluteString, label: "ticket URL")
89 } label: {
90 SwiftUI.Label("Copy URL", systemImage: "doc.on.doc")
91 }
92 }
93
94 Button {
95 appState.copyToPasteboard(String(ticketId), label: "ticket ID")
96 } label: {
97 SwiftUI.Label("Copy Ticket ID", systemImage: "number")
98 }
99
100 Button {
101 appState.copyToPasteboard(trackerRid, label: "tracker RID")
102 } label: {
103 SwiftUI.Label("Copy Tracker RID", systemImage: "number")
104 }
105
106 Divider()
107
108 Button {
109 Task { await viewModel.toggleSubscription() }
110 } label: {
111 if viewModel.isSubscribed {
112 SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
113 } else {
114 SwiftUI.Label("Subscribe", systemImage: "bell")
115 }
116 }
117 .disabled(viewModel.isPerformingAction)
118
119 if isOwnedByCurrentUser {
120 Divider()
121
122 if viewModel.ticket != nil {
123 Button {
124 showEditSheet = true
125 } label: {
126 SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil")
127 }
128 }
129
130 if let ticket = viewModel.ticket {
131 if ticket.status == .resolved {
132 Button {
133 Task {
134 await viewModel.updateStatus(status: .reported)
135 }
136 } label: {
137 SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
138 }
139 } else {
140 Button {
141 showResolveSheet = true
142 } label: {
143 SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
144 }
145 }
146 }
147
148 Button {
149 showAssignSheet = true
150 } label: {
151 SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
152 }
153
154 Button {
155 showLabelsSheet = true
156 Task { await viewModel.loadTrackerLabels() }
157 } label: {
158 SwiftUI.Label("Manage Labels", systemImage: "tag")
159 }
160
161 Divider()
162
163 Button(role: .destructive) {
164 showDeleteConfirmation = true
165 } label: {
166 SwiftUI.Label("Delete Ticket", systemImage: "trash")
167 }
168 }
169 } label: {
170 Image(systemName: "ellipsis.circle")
171 }
172 .accessibilityLabel("Ticket actions")
173 .sheet(isPresented: $showResolveSheet) {
174 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
175 .presentationDetents([.medium])
176 }
177 .sheet(isPresented: $showAssignSheet) {
178 AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
179 .presentationDetents([.medium])
180 }
181 .sheet(isPresented: $showLabelsSheet) {
182 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
183 .presentationDetents([.medium])
184 }
185 .sheet(isPresented: $showEditSheet) {
186 if let ticket = viewModel.ticket {
187 EditTicketSheet(
188 viewModel: viewModel,
189 isPresented: $showEditSheet,
190 initialSubject: ticket.title,
191 initialBody: ticket.description ?? ""
192 )
193 }
194 }
195 .confirmationDialog(
196 "Delete Ticket #\(ticketId)?",
197 isPresented: $showDeleteConfirmation,
198 titleVisibility: .visible
199 ) {
200 Button("Delete Ticket", role: .destructive) {
201 Task {
202 if await viewModel.deleteTicket() {
203 dismiss()
204 }
205 }
206 }
207 Button("Cancel", role: .cancel) { /* dismisses the dialog; no action needed */ }
208 } message: {
209 Text("This permanently deletes the ticket and its comments. This cannot be undone.")
210 }
211 }
212
213 // MARK: - Detail Content
214
215 @ViewBuilder
216 private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
217 @Bindable var vm = viewModel
218
219 if viewModel.isLoading, viewModel.ticket == nil {
220 SRHTLoadingStateView(message: "Loading ticket…")
221 } else if let error = viewModel.error, viewModel.ticket == nil {
222 SRHTErrorStateView(
223 title: "Couldn't Load Ticket",
224 message: error,
225 retryAction: { await reloadDetail(viewModel) }
226 )
227 } else if let ticket = viewModel.ticket {
228 ScrollView {
229 VStack(alignment: .leading, spacing: 0) {
230 if let cacheMetadata = viewModel.cacheMetadata {
231 StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
232 .padding(.horizontal)
233 .padding(.vertical, 8)
234 }
235
236 // Header
237 ticketHeader(ticket, viewModel: viewModel)
238
239 Divider()
240 .padding(.vertical, 12)
241
242 // Description
243 if let description = ticket.description, !description.isEmpty {
244 MarkdownContentView(markdown: description)
245 .padding(.horizontal)
246 .padding(.bottom, 16)
247
248 Divider()
249 .padding(.bottom, 12)
250 }
251
252 // Event timeline
253 if !viewModel.events.isEmpty {
254 Text("Activity")
255 .font(.headline)
256 .padding(.horizontal)
257 .padding(.bottom, 8)
258
259 LazyVStack(alignment: .leading, spacing: 0) {
260 ForEach(viewModel.events) { event in
261 EventRow(
262 event: event,
263 ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
264 ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
265 )
266 if event.id != viewModel.events.last?.id {
267 Divider()
268 .padding(.leading, 40)
269 }
270 }
271 }
272
273 Divider()
274 .padding(.vertical, 12)
275 }
276
277 if appState.isDebugModeEnabled {
278 Divider()
279 .padding(.vertical, 12)
280
281 debugSection(viewModel: viewModel, ticket: ticket)
282 }
283
284 commentInput(viewModel)
285 }
286 }
287 .task(id: ticket.id) {
288 RecentActivityStore.recordTicket(
289 ownerUsername: ownerUsername,
290 trackerName: trackerName,
291 ticketId: ticket.id,
292 title: ticket.title,
293 defaults: appState.accountDefaults
294 )
295 }
296 .srhtErrorBanner(error: $vm.error)
297 .refreshable {
298 await reloadDetail(viewModel)
299 }
300 }
301 }
302
303 // MARK: - Header
304
305 @ViewBuilder
306 private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
307 VStack(alignment: .leading, spacing: 8) {
308 HStack(alignment: .top, spacing: 12) {
309 Text(ticket.title)
310 .font(.title3.weight(.semibold))
311
312 Spacer(minLength: 12)
313
314 if isOwnedByCurrentUser {
315 assignToMeButton(ticket: ticket, viewModel: viewModel)
316 }
317 }
318
319 HStack(spacing: 8) {
320 TicketStatusIcon(status: ticket.status)
321 Text(ticket.status.displayName)
322 .font(.subheadline.weight(.medium))
323
324 if ticket.status == .resolved, let resolution = ticket.resolution {
325 Text("(\(resolution.displayName))")
326 .font(.subheadline)
327 .foregroundStyle(.secondary)
328 }
329 }
330
331 HStack(spacing: 4) {
332 Text("Opened by")
333 .foregroundStyle(.secondary)
334 Text(ticket.submitter.canonicalName)
335 .fontWeight(.medium)
336 Text(ticket.created.relativeDescription)
337 .foregroundStyle(.tertiary)
338 }
339 .font(.caption)
340
341 Button {
342 openTracker()
343 } label: {
344 HStack(spacing: 6) {
345 Label("\(ownerUsername)/\(trackerName)", systemImage: "checklist")
346 .font(.caption.weight(.medium))
347 if isOpeningTracker {
348 ProgressView()
349 .controlSize(.small)
350 } else {
351 Image(systemName: "arrow.up.right")
352 .font(.caption2)
353 .foregroundStyle(.tertiary)
354 }
355 }
356 .foregroundStyle(.secondary)
357 }
358 .buttonStyle(.plain)
359 .disabled(isOpeningTracker)
360
361 if !ticket.assignees.isEmpty {
362 HStack(spacing: 4) {
363 Image(systemName: "person.fill")
364 .font(.caption2)
365 .foregroundStyle(.secondary)
366 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
367 .font(.caption)
368 .foregroundStyle(.secondary)
369 }
370 }
371
372 if !ticket.labels.isEmpty {
373 FlowLayout(spacing: 4) {
374 ForEach(ticket.labels) { label in
375 LabelPill(label: label)
376 }
377 }
378 }
379 }
380 .padding()
381 }
382
383 @ViewBuilder
384 private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
385 if let currentUser = appState.currentUser {
386 let isAssignedToCurrentUser = ticket.assignees.contains {
387 TicketDetailViewModel.matchesAssignee($0, user: currentUser)
388 }
389
390 if isAssignedToCurrentUser {
391 Label("Assigned to you", systemImage: "checkmark.circle.fill")
392 .font(.caption.weight(.medium))
393 .foregroundStyle(.secondary)
394 .padding(.horizontal, 10)
395 .padding(.vertical, 6)
396 .background(Color(.secondarySystemFill), in: Capsule())
397 } else {
398 Button {
399 Task {
400 await viewModel.assignToCurrentUser(currentUser)
401 }
402 } label: {
403 if viewModel.isPerformingAction {
404 ProgressView()
405 .controlSize(.small)
406 .frame(minWidth: 88)
407 } else {
408 Text("Assign to Me")
409 .font(.caption.weight(.semibold))
410 .frame(minWidth: 88)
411 }
412 }
413 .buttonStyle(.borderedProminent)
414 .controlSize(.small)
415 .disabled(viewModel.isPerformingAction)
416 }
417 }
418 }
419
420 // MARK: - Comment Input
421
422 @ViewBuilder
423 private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
424 @Bindable var vm = viewModel
425
426 VStack(alignment: .leading, spacing: 8) {
427 Text("New Comment")
428 .font(.headline)
429
430 Picker("Mode", selection: $commentMode) {
431 ForEach(CommentMode.allCases, id: \.self) { mode in
432 Text(mode.rawValue).tag(mode)
433 }
434 }
435 .pickerStyle(.segmented)
436
437 if commentMode == .write {
438 TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
439 .textFieldStyle(.roundedBorder)
440 .lineLimit(3...8)
441 } else {
442 // Markdown preview
443 if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
444 Text("Nothing to preview")
445 .font(.subheadline)
446 .foregroundStyle(.secondary)
447 .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
448 .background(Color(.secondarySystemBackground))
449 .clipShape(RoundedRectangle(cornerRadius: 8))
450 } else {
451 MarkdownContentView(markdown: viewModel.commentText)
452 .frame(minHeight: 80, maxHeight: 200)
453 .clipShape(RoundedRectangle(cornerRadius: 8))
454 }
455 }
456
457 HStack {
458 Spacer()
459 Button {
460 Task { await viewModel.submitComment() }
461 } label: {
462 if viewModel.isSubmitting {
463 ProgressView()
464 .controlSize(.small)
465 } else {
466 Text("Post Comment")
467 }
468 }
469 .buttonStyle(.borderedProminent)
470 .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
471 }
472 }
473 .padding()
474 }
475
476 @ViewBuilder
477 private func debugSection(viewModel: TicketDetailViewModel, ticket: TicketDetail) -> some View {
478 VStack(alignment: .leading, spacing: 12) {
479 Text("Debug")
480 .font(.headline)
481 .padding(.horizontal)
482
483 VStack(alignment: .leading, spacing: 12) {
484 DebugTextBlock(
485 title: "Diagnostics",
486 content: """
487 ticketId: \(ticket.id)
488 trackerId: \(trackerId)
489 trackerRid: \(trackerRid)
490 status: \(ticket.status.rawValue)
491 events: \(viewModel.events.count)
492 url: \(SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticket.id)?.absoluteString ?? "unavailable")
493 """
494 )
495
496 if let rawTicketResponse = viewModel.rawTicketResponse {
497 DebugTextBlock(title: "Raw Response", content: rawTicketResponse)
498 }
499 }
500 .padding(.horizontal)
501 .padding(.bottom, 16)
502 }
503 }
504
505 private func normalizedUsername(_ value: String) -> String {
506 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
507 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
508 }
509
510 private func openTracker() {
511 guard !isOpeningTracker else { return }
512 isOpeningTracker = true
513 Task {
514 defer { isOpeningTracker = false }
515 do {
516 let tracker = try await appState.resolveTracker(owner: ownerUsername, name: trackerName)
517 appState.navigateToTracker(tracker)
518 } catch {
519 appState.presentTicketDeepLinkError()
520 }
521 }
522 }
523
524 private func reloadDetail(_ viewModel: TicketDetailViewModel) async {
525 if appState.isDebugModeEnabled {
526 await viewModel.loadTicketWithDebugCapture()
527 } else {
528 await viewModel.loadTicket()
529 }
530 }
531}
532
533// MARK: - Self-Sizing Markdown Web View
534
535/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
536/// fit the rendered content. Reuses the same `markdownToHTML` converter and
537/// styling as the README renderer.
538private struct MarkdownContentView: View {
539 let markdown: String
540 @Environment(\.colorScheme) private var colorScheme
541 @State private var renderedHTML: String?
542
543 var body: some View {
544 Group {
545 if let renderedHTML {
546 HTMLWebView(
547 html: renderedHTML,
548 colorScheme: colorScheme,
549 style: .commentPreview
550 )
551 } else {
552 SRHTLoadingStateView(message: "Preparing content…")
553 .frame(minHeight: 80)
554 }
555 }
556 .task(id: MarkdownRenderKey(markdown: markdown, isDark: colorScheme == .dark)) {
557 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
558 let html = await Task.detached(priority: .userInitiated) {
559 markdownToHTML(markdown, codeTheme: theme)
560 }.value
561 guard !Task.isCancelled else { return }
562 renderedHTML = html
563 }
564 }
565
566 /// Re-renders when the markdown or the color scheme changes; highlighted
567 /// code colors are baked into the HTML, so both must key the render.
568 private struct MarkdownRenderKey: Equatable {
569 let markdown: String
570 let isDark: Bool
571 }
572}
573
574// MARK: - Event Row
575
576private struct EventRow: View {
577 let event: TicketEvent
578 let ticketSubmitter: String?
579 let ticketAssignees: [String]?
580 @State private var isShowingSystemStatusInfo = false
581
582 var body: some View {
583 ForEach(event.changes) { change in
584 HStack(alignment: .top, spacing: 12) {
585 Image(systemName: icon(for: change))
586 .foregroundStyle(color(for: change))
587 .frame(width: 24)
588 .padding(.top, 2)
589
590 VStack(alignment: .leading, spacing: 4) {
591 HStack {
592 if kind(for: change) == .comment {
593 Text(change.author?.canonicalName ?? "")
594 .font(.subheadline.weight(.medium))
595 } else {
596 let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
597 if descriptionText.hasPrefix("System") {
598 HStack(spacing: 4) {
599 Text(descriptionText)
600 .font(.subheadline)
601 .foregroundStyle(.secondary)
602 Button {
603 isShowingSystemStatusInfo = true
604 } label: {
605 Image(systemName: "info.circle")
606 .foregroundStyle(.gray)
607 }
608 .buttonStyle(.plain)
609 }
610 } else {
611 Text(descriptionText)
612 .font(.subheadline)
613 .foregroundStyle(.secondary)
614 }
615 }
616 Spacer()
617 Text(event.created.relativeDescription)
618 .font(.caption)
619 .foregroundStyle(.tertiary)
620 }
621 if kind(for: change) == .comment, let text = change.text {
622 MarkdownContentView(markdown: text)
623 }
624 }
625 }
626 .padding(.horizontal)
627 .padding(.vertical, 8)
628 .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
629 Button("OK", role: .cancel) {
630 // Alert dismissal is implicit; no additional action required.
631 }
632 } message: {
633 Text("This status change was recorded automatically or without a named user attached to the event.")
634 }
635 }
636 }
637
638 private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
639 let eventKind = kind(for: change)
640 let authorName: String
641
642 if let commentAuthor = event.changes.first(where: {
643 kind(for: $0) == .comment && $0.author != nil
644 })?.author?.canonicalName {
645 authorName = commentAuthor
646 } else {
647 switch eventKind {
648 case .created:
649 authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
650 case .statusChange:
651 authorName = "System"
652 case .labelAdded, .labelRemoved, .labelUpdated:
653 authorName = change.labeler?.canonicalName ?? "Someone"
654 case .assigned, .unassigned:
655 authorName = change.assigner?.canonicalName ?? "Someone"
656 case .comment:
657 authorName = change.author?.canonicalName ?? "Someone"
658 case .ticketMention, .userMention:
659 authorName = change.author?.canonicalName
660 ?? change.assigner?.canonicalName
661 ?? change.labeler?.canonicalName
662 ?? "Someone"
663 case .unknown:
664 authorName = change.author?.canonicalName
665 ?? change.assigner?.canonicalName
666 ?? change.labeler?.canonicalName
667 ?? change.assignee?.canonicalName
668 ?? change.mentioned?.canonicalName
669 ?? ticketAssignees?.first
670 ?? "Someone"
671 }
672 }
673
674 switch eventKind {
675 case .statusChange:
676 let oldStatus = change.oldStatus?.displayName ?? "unknown"
677 let newStatus = change.newStatus?.displayName ?? "unknown"
678 return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
679 case .labelUpdated, .labelAdded:
680 let labelName = change.label?.name ?? "a label"
681 let verb = eventKind == .labelAdded ? "added" : "updated"
682 return "\(authorName) \(verb) label \"\(labelName)\""
683 case .labelRemoved:
684 let labelName = change.label?.name ?? "a label"
685 return "\(authorName) removed label \"\(labelName)\""
686 case .assigned:
687 let assigneeName = change.assignee?.canonicalName ?? "someone"
688 return "\(authorName) assigned \(assigneeName)"
689 case .unassigned:
690 let assigneeName = change.assignee?.canonicalName ?? "someone"
691 return "\(authorName) unassigned \(assigneeName)"
692 case .ticketMention:
693 if let ticketId = change.mentioned?.id {
694 return "\(authorName) mentioned ticket #\(ticketId)"
695 }
696 return "\(authorName) mentioned another ticket"
697 case .userMention:
698 let user = change.mentioned?.canonicalName ?? "someone"
699 return "\(authorName) mentioned \(user)"
700 case .created:
701 return "\(authorName) opened this ticket"
702 case .comment:
703 return "\(authorName) commented"
704 case .unknown:
705 return "\(authorName) updated this ticket"
706 }
707 }
708
709 private func icon(for change: EventChange) -> String {
710 switch kind(for: change) {
711 case .comment:
712 "text.bubble"
713 case .statusChange:
714 "arrow.triangle.2.circlepath"
715 case .labelAdded, .labelRemoved, .labelUpdated:
716 "tag"
717 case .assigned:
718 "person.badge.plus"
719 case .unassigned:
720 "person.badge.minus"
721 case .ticketMention, .userMention:
722 "at"
723 case .created:
724 "plus.circle"
725 case .unknown:
726 "circle.fill"
727 }
728 }
729
730 private func color(for change: EventChange) -> Color {
731 switch kind(for: change) {
732 case .comment:
733 .blue
734 case .statusChange:
735 change.newStatus == .resolved ? .green : .orange
736 case .labelAdded, .labelRemoved, .labelUpdated:
737 .purple
738 case .assigned, .unassigned:
739 .cyan
740 case .ticketMention, .userMention:
741 .indigo
742 case .created:
743 .green
744 case .unknown:
745 .gray
746 }
747 }
748
749 private func kind(for change: EventChange) -> EventKind {
750 switch change.eventType {
751 case "COMMENT", "Comment":
752 .comment
753 case "STATUS_CHANGE", "StatusChange":
754 .statusChange
755 case "LABEL_UPDATE", "LabelUpdate":
756 .labelUpdated
757 case "LABEL_ADDED", "LabelAdded":
758 .labelAdded
759 case "LABEL_REMOVED", "LabelRemoved":
760 .labelRemoved
761 case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
762 .assigned
763 case "UNASSIGNED_USER", "UnassignedUser":
764 .unassigned
765 case "TICKET_MENTION", "TicketMention":
766 .ticketMention
767 case "USER_MENTION", "UserMention":
768 .userMention
769 case "CREATED", "Created":
770 .created
771 default:
772 .unknown
773 }
774 }
775
776 private enum EventKind: Equatable {
777 case comment
778 case statusChange
779 case labelUpdated
780 case labelAdded
781 case labelRemoved
782 case assigned
783 case unassigned
784 case ticketMention
785 case userMention
786 case created
787 case unknown
788 }
789}
790
791// MARK: - Resolve Sheet
792
793private struct EditTicketSheet: View {
794 let viewModel: TicketDetailViewModel
795 @Binding var isPresented: Bool
796 let initialSubject: String
797 let initialBody: String
798
799 @State private var subject: String
800 @State private var ticketBody: String
801
802 init(
803 viewModel: TicketDetailViewModel,
804 isPresented: Binding<Bool>,
805 initialSubject: String,
806 initialBody: String
807 ) {
808 self.viewModel = viewModel
809 _isPresented = isPresented
810 self.initialSubject = initialSubject
811 self.initialBody = initialBody
812 _subject = State(initialValue: initialSubject)
813 _ticketBody = State(initialValue: initialBody)
814 }
815
816 private var trimmedSubject: String {
817 subject.trimmingCharacters(in: .whitespacesAndNewlines)
818 }
819
820 private var hasChanges: Bool {
821 trimmedSubject != initialSubject
822 || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody
823 }
824
825 var body: some View {
826 NavigationStack {
827 Form {
828 Section("Subject") {
829 TextField("Subject", text: $subject, axis: .vertical)
830 .themedRow()
831 }
832
833 Section("Description") {
834 TextField("Description", text: $ticketBody, axis: .vertical)
835 .lineLimit(5...15)
836 .themedRow()
837 }
838 }
839 .themedList()
840 .navigationTitle("Edit Ticket")
841 .navigationBarTitleDisplayMode(.inline)
842 .toolbar {
843 ToolbarItem(placement: .cancellationAction) {
844 Button("Cancel") { isPresented = false }
845 }
846 ToolbarItem(placement: .confirmationAction) {
847 Button("Save") {
848 Task {
849 if await viewModel.updateTicket(subject: subject, body: ticketBody) {
850 isPresented = false
851 }
852 }
853 }
854 .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges)
855 }
856 }
857 .overlay {
858 if viewModel.isPerformingAction {
859 ProgressView()
860 }
861 }
862 }
863 }
864}
865
866// MARK: - Resolve Sheet
867
868private struct ResolveSheet: View {
869 let viewModel: TicketDetailViewModel
870 @Binding var isPresented: Bool
871 @State private var selectedResolution: TicketResolution = .fixed
872
873 private static let resolutionOptions: [TicketResolution] = [
874 .closed, .fixed, .implemented, .wontFix,
875 .byDesign, .invalid, .duplicate, .notOurBug
876 ]
877
878 var body: some View {
879 NavigationStack {
880 Form {
881 Section("Resolution") {
882 Picker("Resolution", selection: $selectedResolution) {
883 ForEach(Self.resolutionOptions, id: \.self) { resolution in
884 Text(resolution.displayName).tag(resolution)
885 }
886 }
887 .pickerStyle(.inline)
888 .labelsHidden()
889 .themedRow()
890 }
891 }
892 .themedList()
893 .navigationTitle("Resolve Ticket")
894 .navigationBarTitleDisplayMode(.inline)
895 .toolbar {
896 ToolbarItem(placement: .cancellationAction) {
897 Button("Cancel") { isPresented = false }
898 }
899 ToolbarItem(placement: .confirmationAction) {
900 Button("Mark Resolved") {
901 Task {
902 await viewModel.updateStatus(
903 status: .resolved,
904 resolution: selectedResolution
905 )
906 if viewModel.error == nil {
907 isPresented = false
908 }
909 }
910 }
911 .disabled(viewModel.isPerformingAction)
912 }
913 }
914 .overlay {
915 if viewModel.isPerformingAction {
916 ProgressView()
917 }
918 }
919 }
920 }
921}
922
923// MARK: - Assign Sheet
924
925private struct AssignSheet: View {
926 let viewModel: TicketDetailViewModel
927 @Binding var isPresented: Bool
928 @State private var username = ""
929
930 var body: some View {
931 NavigationStack {
932 Form {
933 // Current assignees with remove buttons
934 if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
935 Section("Current Assignees") {
936 ForEach(ticket.assignees, id: \.canonicalName) { assignee in
937 HStack {
938 Text(assignee.canonicalName)
939 Spacer()
940 Button(role: .destructive) {
941 Task {
942 await viewModel.unassignUser(
943 username: assignee.canonicalName
944 )
945 }
946 } label: {
947 Image(systemName: "minus.circle.fill")
948 .foregroundStyle(.red)
949 }
950 .buttonStyle(.plain)
951 }
952 }
953 .themedRow()
954 }
955 }
956
957 Section("Add Assignee") {
958 TextField("Username or ~username", text: $username)
959 .textContentType(.username)
960 .autocorrectionDisabled()
961 .textInputAutocapitalization(.never)
962 .themedRow()
963
964 Button("Add Assignee") {
965 let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
966 guard !name.isEmpty else { return }
967 Task {
968 await viewModel.assignUser(username: name)
969 username = ""
970 }
971 }
972 .disabled(
973 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
974 || viewModel.isPerformingAction
975 )
976 .themedRow()
977 }
978 }
979 .themedList()
980 .navigationTitle("Assignees")
981 .navigationBarTitleDisplayMode(.inline)
982 .toolbar {
983 ToolbarItem(placement: .confirmationAction) {
984 Button("Done") { isPresented = false }
985 }
986 }
987 .overlay {
988 if viewModel.isPerformingAction {
989 ProgressView()
990 }
991 }
992 }
993 }
994}
995
996// MARK: - Labels Sheet
997
998private struct LabelsSheet: View {
999 let viewModel: TicketDetailViewModel
1000 @Binding var isPresented: Bool
1001 @State private var showCreateLabel = false
1002
1003 var body: some View {
1004 NavigationStack {
1005 Group {
1006 if viewModel.trackerLabels.isEmpty {
1007 if viewModel.isPerformingAction {
1008 ProgressView()
1009 } else {
1010 ContentUnavailableView(
1011 "No Labels",
1012 systemImage: "tag",
1013 description: Text("This tracker has no labels defined.")
1014 )
1015 }
1016 } else {
1017 List {
1018 ForEach(viewModel.trackerLabels) { label in
1019 LabelToggleRow(
1020 label: label,
1021 isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
1022 isLoading: viewModel.isPerformingAction
1023 ) { shouldApply in
1024 Task {
1025 if shouldApply {
1026 await viewModel.labelTicket(labelId: label.id)
1027 } else {
1028 await viewModel.unlabelTicket(labelId: label.id)
1029 }
1030 }
1031 }
1032 }
1033 .themedRow()
1034 }
1035 .themedList()
1036 }
1037 }
1038 .navigationTitle("Labels")
1039 .navigationBarTitleDisplayMode(.inline)
1040 .toolbar {
1041 ToolbarItem(placement: .cancellationAction) {
1042 Button("Done") { isPresented = false }
1043 }
1044 ToolbarItem(placement: .primaryAction) {
1045 Button {
1046 showCreateLabel = true
1047 } label: {
1048 SwiftUI.Label("New Label", systemImage: "plus")
1049 }
1050 }
1051 }
1052 .sheet(isPresented: $showCreateLabel) {
1053 CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
1054 .presentationDetents([.medium])
1055 }
1056 }
1057 }
1058}
1059
1060// MARK: - Create Label Sheet
1061
1062private struct CreateLabelSheet: View {
1063 let viewModel: TicketDetailViewModel
1064 @Binding var isPresented: Bool
1065 @State private var labelName = ""
1066 @State private var backgroundColor = Color.blue
1067 @State private var foregroundColor = Color.white
1068
1069 var body: some View {
1070 NavigationStack {
1071 Form {
1072 Section("Label Details") {
1073 TextField("Label name", text: $labelName)
1074 .autocorrectionDisabled()
1075 .themedRow()
1076 }
1077
1078 Section("Colors") {
1079 ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
1080 .themedRow()
1081 ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
1082 .themedRow()
1083 }
1084
1085 Section("Preview") {
1086 HStack {
1087 Spacer()
1088 Text(labelName.isEmpty ? "Label" : labelName)
1089 .font(.caption2.weight(.medium))
1090 .padding(.horizontal, 6)
1091 .padding(.vertical, 2)
1092 .background(backgroundColor)
1093 .foregroundStyle(foregroundColor)
1094 .clipShape(Capsule())
1095 Spacer()
1096 }
1097 .themedRow()
1098 }
1099 }
1100 .themedList()
1101 .navigationTitle("New Label")
1102 .navigationBarTitleDisplayMode(.inline)
1103 .toolbar {
1104 ToolbarItem(placement: .cancellationAction) {
1105 Button("Cancel") { isPresented = false }
1106 }
1107 ToolbarItem(placement: .confirmationAction) {
1108 Button("Create Label") {
1109 Task {
1110 await viewModel.createLabel(
1111 name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
1112 backgroundColor: backgroundColor.hexString,
1113 foregroundColor: foregroundColor.hexString
1114 )
1115 if viewModel.error == nil {
1116 isPresented = false
1117 }
1118 }
1119 }
1120 .disabled(
1121 labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1122 || viewModel.isPerformingAction
1123 )
1124 }
1125 }
1126 .overlay {
1127 if viewModel.isPerformingAction {
1128 ProgressView()
1129 }
1130 }
1131 }
1132 }
1133}
1134
1135private struct LabelToggleRow: View {
1136 let label: TicketLabel
1137 let isApplied: Bool
1138 let isLoading: Bool
1139 let onToggle: (Bool) -> Void
1140
1141 var body: some View {
1142 Button {
1143 onToggle(!isApplied)
1144 } label: {
1145 HStack {
1146 LabelPill(label: label)
1147 Spacer()
1148 if isApplied {
1149 Image(systemName: "checkmark")
1150 .foregroundStyle(.blue)
1151 }
1152 }
1153 }
1154 .disabled(isLoading)
1155 }
1156}