krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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