krz/hutch

an ios client for sourcehut

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

v3.11.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}