krz/hutch

an ios client for sourcehut

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

main: Hutch/Views/Tickets/TrackerManagementView.swift · raw

   1import SwiftUI
   2
   3private struct TrackerQueryResponse: Decodable, Sendable {
   4    let tracker: TrackerSummary?
   5}
   6
   7private struct TrackerACLQueryResponse: Decodable, Sendable {
   8    let tracker: TrackerACLQueryPayload?
   9}
  10
  11private struct TrackerACLQueryPayload: Decodable, Sendable {
  12    let defaultACL: DefaultTrackerACL
  13    let acls: TrackerACLPage
  14}
  15
  16private struct TrackerACLPage: Decodable, Sendable {
  17    let results: [TrackerACL]
  18    let cursor: String?
  19}
  20
  21private struct TrackerLabelQueryResponse: Decodable, Sendable {
  22    let tracker: TrackerLabelQueryPayload?
  23}
  24
  25private struct TrackerLabelQueryPayload: Decodable, Sendable {
  26    let labels: TrackerLabelPage
  27}
  28
  29private struct TrackerLabelPage: Decodable, Sendable {
  30    let results: [TicketLabel]
  31    let cursor: String?
  32}
  33
  34private struct UpdateTrackerResponse: Decodable, Sendable {
  35    let updateTracker: TrackerSummary
  36}
  37
  38private struct DeleteTrackerResponse: Decodable, Sendable {
  39    let deleteTracker: DeletedTracker
  40}
  41
  42private struct DeletedTracker: Decodable, Sendable {
  43    let id: Int
  44}
  45
  46private struct UpdateUserACLResponse: Decodable, Sendable {
  47    let updateUserACL: TrackerACL
  48}
  49
  50private struct UpdateTrackerACLResponse: Decodable, Sendable {
  51    let updateTrackerACL: DefaultTrackerACL
  52}
  53
  54private struct DeleteTrackerACLResponse: Decodable, Sendable {
  55    let deleteACL: TrackerACL
  56}
  57
  58private struct CreateTrackerLabelResponse: Decodable, Sendable {
  59    let createLabel: TicketLabel
  60}
  61
  62private struct UpdateTrackerLabelResponse: Decodable, Sendable {
  63    let updateLabel: TicketLabel
  64}
  65
  66private struct DeleteTrackerLabelResponse: Decodable, Sendable {
  67    let deleteLabel: TicketLabel
  68}
  69
  70private struct TrackerUserLookupResponse: Decodable, Sendable {
  71    let user: UserIdPayload?
  72}
  73
  74private struct UserIdPayload: Decodable, Sendable {
  75    let id: Int
  76}
  77
  78@Observable
  79@MainActor
  80final class TrackerManagementViewModel {
  81    private(set) var tracker: TrackerSummary
  82    private(set) var acls: [TrackerACL] = []
  83    private(set) var defaultACL = DefaultTrackerACL(
  84        browse: true,
  85        submit: true,
  86        comment: true,
  87        edit: false,
  88        triage: false
  89    )
  90    private(set) var labels: [TicketLabel] = []
  91
  92    private(set) var isSavingTracker = false
  93    private(set) var isDeletingTracker = false
  94    private(set) var isLoadingACLs = false
  95    private(set) var isSavingACL = false
  96    private(set) var isDeletingACL = false
  97    private(set) var isLoadingLabels = false
  98    private(set) var isSavingLabel = false
  99    private(set) var isDeletingLabel = false
 100
 101    var error: String?
 102    var didDeleteTracker = false
 103
 104    private let client: SRHTClient
 105
 106    init(tracker: TrackerSummary, client: SRHTClient) {
 107        self.tracker = tracker
 108        self.client = client
 109    }
 110
 111    private static let trackerQuery = """
 112    query tracker($rid: ID!) {
 113        tracker(rid: $rid) {
 114            id
 115            rid
 116            name
 117            description
 118            visibility
 119            updated
 120            owner { canonicalName }
 121        }
 122    }
 123    """
 124
 125    private static let trackerACLsQuery = """
 126    query trackerACLs($rid: ID!, $cursor: Cursor) {
 127        tracker(rid: $rid) {
 128            defaultACL {
 129                browse
 130                submit
 131                comment
 132                edit
 133                triage
 134            }
 135            acls(cursor: $cursor) {
 136                results {
 137                    id
 138                    created
 139                    entity { canonicalName }
 140                    browse
 141                    submit
 142                    comment
 143                    edit
 144                    triage
 145                }
 146                cursor
 147            }
 148        }
 149    }
 150    """
 151
 152    private static let trackerLabelsQuery = """
 153    query trackerLabels($rid: ID!, $cursor: Cursor) {
 154        tracker(rid: $rid) {
 155            labels(cursor: $cursor) {
 156                results {
 157                    id
 158                    name
 159                    backgroundColor
 160                    foregroundColor
 161                }
 162                cursor
 163            }
 164        }
 165    }
 166    """
 167
 168    private static let updateTrackerMutation = """
 169    mutation updateTracker($id: Int!, $input: TrackerInput!) {
 170        updateTracker(id: $id, input: $input) {
 171            id
 172            rid
 173            name
 174            description
 175            visibility
 176            updated
 177            owner { canonicalName }
 178        }
 179    }
 180    """
 181
 182    private static let deleteTrackerMutation = """
 183    mutation deleteTracker($id: Int!) {
 184        deleteTracker(id: $id) {
 185            id
 186        }
 187    }
 188    """
 189
 190    private static let updateUserACLMutation = """
 191    mutation updateUserACL($trackerId: Int!, $userId: Int!, $input: ACLInput!) {
 192        updateUserACL(trackerId: $trackerId, userId: $userId, input: $input) {
 193            id
 194            created
 195            entity { canonicalName }
 196            browse
 197            submit
 198            comment
 199            edit
 200            triage
 201        }
 202    }
 203    """
 204
 205    private static let updateTrackerACLMutation = """
 206    mutation updateTrackerACL($trackerId: Int!, $input: ACLInput!) {
 207        updateTrackerACL(trackerId: $trackerId, input: $input) {
 208            browse
 209            submit
 210            comment
 211            edit
 212            triage
 213        }
 214    }
 215    """
 216
 217    private static let deleteACLMutation = """
 218    mutation deleteACL($id: Int!) {
 219        deleteACL(id: $id) {
 220            id
 221            created
 222            entity { canonicalName }
 223            browse
 224            submit
 225            comment
 226            edit
 227            triage
 228        }
 229    }
 230    """
 231
 232    private static let createLabelMutation = """
 233    mutation createLabel($trackerId: Int!, $name: String!, $foregroundColor: String!, $backgroundColor: String!) {
 234        createLabel(trackerId: $trackerId, name: $name, foregroundColor: $foregroundColor, backgroundColor: $backgroundColor) {
 235            id
 236            name
 237            backgroundColor
 238            foregroundColor
 239        }
 240    }
 241    """
 242
 243    private static let updateLabelMutation = """
 244    mutation updateLabel($id: Int!, $input: UpdateLabelInput!) {
 245        updateLabel(id: $id, input: $input) {
 246            id
 247            name
 248            backgroundColor
 249            foregroundColor
 250        }
 251    }
 252    """
 253
 254    private static let deleteLabelMutation = """
 255    mutation deleteLabel($id: Int!) {
 256        deleteLabel(id: $id) {
 257            id
 258            name
 259            backgroundColor
 260            foregroundColor
 261        }
 262    }
 263    """
 264
 265    private static let userLookupQuery = """
 266    query userLookup($username: String!) {
 267        user(username: $username) {
 268            id
 269        }
 270    }
 271    """
 272
 273    func refreshTracker() async {
 274        do {
 275            let result = try await client.execute(
 276                service: .todo,
 277                query: Self.trackerQuery,
 278                variables: ["rid": tracker.rid],
 279                responseType: TrackerQueryResponse.self
 280            )
 281            if let tracker = result.tracker {
 282                self.tracker = tracker
 283            }
 284        } catch {
 285            self.error = error.userFacingMessage
 286        }
 287    }
 288
 289    func updateTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
 290        guard !isSavingTracker else { return nil }
 291        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
 292        guard !trimmedName.isEmpty else {
 293            error = "Enter a tracker name."
 294            return nil
 295        }
 296
 297        isSavingTracker = true
 298        error = nil
 299        defer { isSavingTracker = false }
 300
 301        var input: [String: any Sendable] = [
 302            "name": trimmedName,
 303            "visibility": visibility.rawValue
 304        ]
 305        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
 306        input["description"] = trimmedDescription.isEmpty ? "" : trimmedDescription
 307
 308        do {
 309            let result = try await client.execute(
 310                service: .todo,
 311                query: Self.updateTrackerMutation,
 312                variables: [
 313                    "id": tracker.id,
 314                    "input": input
 315                ],
 316                responseType: UpdateTrackerResponse.self
 317            )
 318            tracker = result.updateTracker
 319            return result.updateTracker
 320        } catch {
 321            self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
 322            return nil
 323        }
 324    }
 325
 326    func deleteTracker() async -> Bool {
 327        guard !isDeletingTracker else { return false }
 328        isDeletingTracker = true
 329        error = nil
 330        defer { isDeletingTracker = false }
 331
 332        do {
 333            _ = try await client.execute(
 334                service: .todo,
 335                query: Self.deleteTrackerMutation,
 336                variables: ["id": tracker.id],
 337                responseType: DeleteTrackerResponse.self
 338            )
 339            didDeleteTracker = true
 340            return true
 341        } catch {
 342            self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
 343            return false
 344        }
 345    }
 346
 347    func loadACLs() async {
 348        guard !isLoadingACLs else { return }
 349        isLoadingACLs = true
 350        error = nil
 351        defer { isLoadingACLs = false }
 352
 353        do {
 354            let result = try await client.execute(
 355                service: .todo,
 356                query: Self.trackerACLsQuery,
 357                variables: ["rid": tracker.rid],
 358                responseType: TrackerACLQueryResponse.self
 359            )
 360            defaultACL = result.tracker?.defaultACL ?? defaultACL
 361            acls = result.tracker?.acls.results ?? []
 362        } catch {
 363            self.error = error.userFacingMessage
 364        }
 365    }
 366
 367    func updateDefaultACL(_ permissions: TrackerACLPermissions) async -> Bool {
 368        guard !isSavingACL else { return false }
 369        isSavingACL = true
 370        error = nil
 371        defer { isSavingACL = false }
 372
 373        do {
 374            let result = try await client.execute(
 375                service: .todo,
 376                query: Self.updateTrackerACLMutation,
 377                variables: [
 378                    "trackerId": tracker.id,
 379                    "input": permissions.graphQLInput
 380                ],
 381                responseType: UpdateTrackerACLResponse.self
 382            )
 383            defaultACL = result.updateTrackerACL
 384            await loadACLs()
 385            return true
 386        } catch {
 387            self.error = error.userFacingMessage
 388            return false
 389        }
 390    }
 391
 392    func addOrUpdateACL(username: String, permissions: TrackerACLPermissions) async -> Bool {
 393        guard !isSavingACL else { return false }
 394        let normalizedUsername = Self.normalizedUsername(username)
 395        guard !normalizedUsername.isEmpty else {
 396            error = "Enter a SourceHut username."
 397            return false
 398        }
 399
 400        isSavingACL = true
 401        error = nil
 402        defer { isSavingACL = false }
 403
 404        do {
 405            let userResult = try await client.execute(
 406                service: .todo,
 407                query: Self.userLookupQuery,
 408                variables: ["username": normalizedUsername],
 409                responseType: TrackerUserLookupResponse.self
 410            )
 411            guard let userId = userResult.user?.id else {
 412                error = "That user couldn’t be found."
 413                return false
 414            }
 415
 416            let result = try await client.execute(
 417                service: .todo,
 418                query: Self.updateUserACLMutation,
 419                variables: [
 420                    "trackerId": tracker.id,
 421                    "userId": userId,
 422                    "input": permissions.graphQLInput
 423                ],
 424                responseType: UpdateUserACLResponse.self
 425            )
 426            if let index = acls.firstIndex(where: { $0.id == result.updateUserACL.id }) {
 427                acls[index] = result.updateUserACL
 428            } else {
 429                acls.append(result.updateUserACL)
 430                acls.sort { $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending }
 431            }
 432            await loadACLs()
 433            return true
 434        } catch {
 435            self.error = error.userFacingMessage
 436            return false
 437        }
 438    }
 439
 440    func deleteACL(_ entry: TrackerACL) async {
 441        guard !isDeletingACL else { return }
 442        isDeletingACL = true
 443        error = nil
 444        defer { isDeletingACL = false }
 445
 446        do {
 447            _ = try await client.execute(
 448                service: .todo,
 449                query: Self.deleteACLMutation,
 450                variables: ["id": entry.id],
 451                responseType: DeleteTrackerACLResponse.self
 452            )
 453            acls.removeAll { $0.id == entry.id }
 454            await loadACLs()
 455        } catch {
 456            self.error = error.userFacingMessage
 457        }
 458    }
 459
 460    func loadLabels() async {
 461        guard !isLoadingLabels else { return }
 462        isLoadingLabels = true
 463        error = nil
 464        defer { isLoadingLabels = false }
 465
 466        do {
 467            let result = try await client.execute(
 468                service: .todo,
 469                query: Self.trackerLabelsQuery,
 470                variables: ["rid": tracker.rid],
 471                responseType: TrackerLabelQueryResponse.self
 472            )
 473            labels = result.tracker?.labels.results ?? []
 474        } catch {
 475            self.error = error.userFacingMessage
 476        }
 477    }
 478
 479    func createLabel(name: String, foregroundColor: String, backgroundColor: String) async -> Bool {
 480        guard !isSavingLabel else { return false }
 481        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
 482        guard !trimmedName.isEmpty else {
 483            error = "Enter a label name."
 484            return false
 485        }
 486        guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
 487            error = "Label colors must use #RRGGBB format."
 488            return false
 489        }
 490
 491        isSavingLabel = true
 492        error = nil
 493        defer { isSavingLabel = false }
 494
 495        do {
 496            _ = try await client.execute(
 497                service: .todo,
 498                query: Self.createLabelMutation,
 499                variables: [
 500                    "trackerId": tracker.id,
 501                    "name": trimmedName,
 502                    "foregroundColor": foregroundColor,
 503                    "backgroundColor": backgroundColor
 504                ],
 505                responseType: CreateTrackerLabelResponse.self
 506            )
 507            await loadLabels()
 508            return true
 509        } catch {
 510            self.error = error.userFacingMessage
 511            return false
 512        }
 513    }
 514
 515    func updateLabel(
 516        _ label: TicketLabel,
 517        name: String,
 518        foregroundColor: String,
 519        backgroundColor: String
 520    ) async -> Bool {
 521        guard !isSavingLabel else { return false }
 522        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
 523        guard !trimmedName.isEmpty else {
 524            error = "Enter a label name."
 525            return false
 526        }
 527        guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
 528            error = "Label colors must use #RRGGBB format."
 529            return false
 530        }
 531
 532        isSavingLabel = true
 533        error = nil
 534        defer { isSavingLabel = false }
 535
 536        var input: [String: any Sendable] = [:]
 537        if trimmedName != label.name {
 538            input["name"] = trimmedName
 539        }
 540        if foregroundColor.caseInsensitiveCompare(label.foregroundColor) != .orderedSame {
 541            input["foregroundColor"] = foregroundColor
 542        }
 543        if backgroundColor.caseInsensitiveCompare(label.backgroundColor) != .orderedSame {
 544            input["backgroundColor"] = backgroundColor
 545        }
 546
 547        guard !input.isEmpty else { return true }
 548
 549        do {
 550            _ = try await client.execute(
 551                service: .todo,
 552                query: Self.updateLabelMutation,
 553                variables: [
 554                    "id": label.id,
 555                    "input": input
 556                ],
 557                responseType: UpdateTrackerLabelResponse.self
 558            )
 559            await loadLabels()
 560            return true
 561        } catch {
 562            self.error = error.userFacingMessage
 563            return false
 564        }
 565    }
 566
 567    func deleteLabel(_ label: TicketLabel) async {
 568        guard !isDeletingLabel else { return }
 569        isDeletingLabel = true
 570        error = nil
 571        defer { isDeletingLabel = false }
 572
 573        do {
 574            _ = try await client.execute(
 575                service: .todo,
 576                query: Self.deleteLabelMutation,
 577                variables: ["id": label.id],
 578                responseType: DeleteTrackerLabelResponse.self
 579            )
 580            labels.removeAll { $0.id == label.id }
 581            await loadLabels()
 582        } catch {
 583            self.error = error.userFacingMessage
 584        }
 585    }
 586
 587    static func normalizedUsername(_ value: String) -> String {
 588        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
 589        guard !trimmed.isEmpty else { return "" }
 590        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
 591    }
 592
 593    static func isValidHexColor(_ value: String) -> Bool {
 594        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
 595        guard trimmed.count == 7, trimmed.first == "#" else { return false }
 596        return trimmed.dropFirst().allSatisfy { $0.isHexDigit }
 597    }
 598}
 599
 600private extension TrackerACLPermissions {
 601    var graphQLInput: [String: any Sendable] {
 602        [
 603            "browse": browse,
 604            "submit": submit,
 605            "comment": comment,
 606            "edit": edit,
 607            "triage": triage
 608        ]
 609    }
 610}
 611
 612struct TrackerEditorSheet: View {
 613    let title: String
 614    let confirmationTitle: String
 615    let isSaving: Bool
 616    let error: String?
 617    let initialName: String
 618    let initialDescription: String
 619    let initialVisibility: Visibility
 620    let onSave: (String, String, Visibility) async -> Bool
 621
 622    @Environment(\.dismiss) private var dismiss
 623    @State private var name: String
 624    @State private var description: String
 625    @State private var visibility: Visibility
 626
 627    init(
 628        title: String,
 629        confirmationTitle: String,
 630        isSaving: Bool,
 631        error: String?,
 632        initialName: String,
 633        initialDescription: String,
 634        initialVisibility: Visibility,
 635        onSave: @escaping (String, String, Visibility) async -> Bool
 636    ) {
 637        self.title = title
 638        self.confirmationTitle = confirmationTitle
 639        self.isSaving = isSaving
 640        self.error = error
 641        self.initialName = initialName
 642        self.initialDescription = initialDescription
 643        self.initialVisibility = initialVisibility
 644        self.onSave = onSave
 645        _name = State(initialValue: initialName)
 646        _description = State(initialValue: initialDescription)
 647        _visibility = State(initialValue: initialVisibility)
 648    }
 649
 650    var body: some View {
 651        NavigationStack {
 652            Form {
 653                Section("Tracker Details") {
 654                    TextField("Tracker name", text: $name)
 655                        .textInputAutocapitalization(.never)
 656                        .autocorrectionDisabled()
 657                        .themedRow()
 658                    TextField("Short description (optional)", text: $description, axis: .vertical)
 659                        .lineLimit(2...4)
 660                        .themedRow()
 661                    Picker("Visibility", selection: $visibility) {
 662                        Text("Public").tag(Visibility.publicVisibility)
 663                        Text("Unlisted").tag(Visibility.unlisted)
 664                        Text("Private").tag(Visibility.privateVisibility)
 665                    }
 666                    .themedRow()
 667                }
 668
 669                if let error, !error.isEmpty {
 670                    Section {
 671                        Text(error)
 672                            .foregroundStyle(.red)
 673                            .themedRow()
 674                    }
 675                }
 676            }
 677            .themedList()
 678            .navigationTitle(title)
 679            .navigationBarTitleDisplayMode(.inline)
 680            .toolbar {
 681                ToolbarItem(placement: .cancellationAction) {
 682                    Button("Cancel") { dismiss() }
 683                }
 684                ToolbarItem(placement: .confirmationAction) {
 685                    Button {
 686                        Task {
 687                            let didSave = await onSave(name, description, visibility)
 688                            if didSave {
 689                                dismiss()
 690                            }
 691                        }
 692                    } label: {
 693                        if isSaving {
 694                            ProgressView()
 695                                .controlSize(.small)
 696                        } else {
 697                            Text(confirmationTitle)
 698                        }
 699                    }
 700                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
 701                }
 702            }
 703        }
 704    }
 705}
 706
 707struct TrackerACLManagementSheet: View {
 708    let viewModel: TrackerManagementViewModel
 709
 710    @Bindable private var bindableViewModel: TrackerManagementViewModel
 711    @State private var editingACL: TrackerACL?
 712    @State private var editingDefaultACL = false
 713    @State private var pendingDeletion: TrackerACL?
 714    @State private var showCreateACL = false
 715
 716    init(viewModel: TrackerManagementViewModel) {
 717        self.viewModel = viewModel
 718        self._bindableViewModel = Bindable(viewModel)
 719    }
 720
 721    var body: some View {
 722        NavigationStack {
 723            List {
 724                Section("Default Access") {
 725                    TrackerPermissionSummary(permissions: viewModel.defaultACL.permissions)
 726                        .themedRow()
 727                    Button("Update Default ACL") {
 728                        editingDefaultACL = true
 729                    }
 730                    .disabled(viewModel.isSavingACL)
 731                    .themedRow()
 732                }
 733
 734                Section {
 735                    if viewModel.isLoadingACLs {
 736                        HStack {
 737                            Spacer()
 738                            ProgressView()
 739                            Spacer()
 740                        }
 741                        .themedRow()
 742                    } else if viewModel.acls.isEmpty {
 743                        Text("No tracker-specific ACLs yet.")
 744                            .foregroundStyle(.secondary)
 745                            .themedRow()
 746                    } else {
 747                        ForEach(viewModel.acls) { entry in
 748                            VStack(alignment: .leading, spacing: 6) {
 749                                Text(entry.entity.canonicalName)
 750                                    .font(.subheadline.weight(.medium))
 751                                TrackerPermissionSummary(permissions: entry.permissions)
 752                            }
 753                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
 754                                Button {
 755                                    pendingDeletion = entry
 756                                } label: {
 757                                    Label("Delete", systemImage: "trash")
 758                                }
 759                                .tint(.red)
 760
 761                                Button {
 762                                    editingACL = entry
 763                                } label: {
 764                                    Label("Edit", systemImage: "pencil")
 765                                }
 766                                .tint(.blue)
 767                            }
 768                        }
 769                        .themedRow()
 770                    }
 771                } header: {
 772                    Text("User ACLs")
 773                } footer: {
 774                    Text("Each ACL must include all five permission flags.")
 775                }
 776            }
 777            .themedList()
 778            .navigationTitle("ACLs")
 779            .navigationBarTitleDisplayMode(.inline)
 780            .toolbar {
 781                ToolbarItem(placement: .topBarTrailing) {
 782                    Button {
 783                        showCreateACL = true
 784                    } label: {
 785                        Image(systemName: "plus")
 786                    }
 787                    .disabled(viewModel.isSavingACL)
 788                    .accessibilityLabel("Add ACL")
 789                }
 790            }
 791            .task {
 792                await viewModel.loadACLs()
 793            }
 794            .srhtErrorBanner(error: $bindableViewModel.error)
 795            .sheet(isPresented: $showCreateACL) {
 796                TrackerACLEditorSheet(
 797                    title: "Add ACL",
 798                    submitTitle: "Save",
 799                    isSaving: viewModel.isSavingACL,
 800                    error: viewModel.error,
 801                    initialUsername: "",
 802                    initialPermissions: viewModel.defaultACL.permissions
 803                ) { username, permissions in
 804                    await viewModel.addOrUpdateACL(username: username, permissions: permissions)
 805                }
 806            }
 807            .sheet(item: $editingACL) { entry in
 808                TrackerACLEditorSheet(
 809                    title: "Update ACL",
 810                    submitTitle: "Save",
 811                    isSaving: viewModel.isSavingACL,
 812                    error: viewModel.error,
 813                    initialUsername: entry.entity.canonicalName,
 814                    initialPermissions: entry.permissions
 815                ) { username, permissions in
 816                    await viewModel.addOrUpdateACL(username: username, permissions: permissions)
 817                }
 818            }
 819            .sheet(isPresented: $editingDefaultACL) {
 820                TrackerDefaultACLEditorSheet(
 821                    isSaving: viewModel.isSavingACL,
 822                    error: viewModel.error,
 823                    initialPermissions: viewModel.defaultACL.permissions
 824                ) { permissions in
 825                    await viewModel.updateDefaultACL(permissions)
 826                }
 827            }
 828            .alert("Remove Access?", isPresented: Binding(
 829                get: { pendingDeletion != nil },
 830                set: { isPresented in
 831                    if !isPresented {
 832                        pendingDeletion = nil
 833                    }
 834                }
 835            )) {
 836                Button("Cancel", role: .cancel) {
 837                    // no-op: .cancel role handles alert dismissal
 838                }
 839                Button("Delete", role: .destructive) {
 840                    guard let pendingDeletion else { return }
 841                    Task {
 842                        await viewModel.deleteACL(pendingDeletion)
 843                        self.pendingDeletion = nil
 844                    }
 845                }
 846            } message: {
 847                if let pendingDeletion {
 848                    Text("\(pendingDeletion.entity.canonicalName) will fall back to the tracker default ACL.")
 849                }
 850            }
 851        }
 852    }
 853}
 854
 855private struct TrackerPermissionSummary: View {
 856    let permissions: TrackerACLPermissions
 857
 858    var body: some View {
 859        Text(summary)
 860            .font(.caption)
 861            .foregroundStyle(.secondary)
 862    }
 863
 864    private var summary: String {
 865        let items = [
 866            permissions.browse ? "browse" : nil,
 867            permissions.submit ? "submit" : nil,
 868            permissions.comment ? "comment" : nil,
 869            permissions.edit ? "edit" : nil,
 870            permissions.triage ? "triage" : nil
 871        ].compactMap { $0 }
 872        return items.isEmpty ? "No permissions" : items.joined(separator: ", ")
 873    }
 874}
 875
 876private struct TrackerACLEditorSheet: View {
 877    let title: String
 878    let submitTitle: String
 879    let isSaving: Bool
 880    let error: String?
 881    let initialUsername: String
 882    let initialPermissions: TrackerACLPermissions
 883    let onSave: (String, TrackerACLPermissions) async -> Bool
 884
 885    @Environment(\.dismiss) private var dismiss
 886    @State private var username: String
 887    @State private var browse: Bool
 888    @State private var submit: Bool
 889    @State private var comment: Bool
 890    @State private var edit: Bool
 891    @State private var triage: Bool
 892
 893    init(
 894        title: String,
 895        submitTitle: String,
 896        isSaving: Bool,
 897        error: String?,
 898        initialUsername: String,
 899        initialPermissions: TrackerACLPermissions,
 900        onSave: @escaping (String, TrackerACLPermissions) async -> Bool
 901    ) {
 902        self.title = title
 903        self.submitTitle = submitTitle
 904        self.isSaving = isSaving
 905        self.error = error
 906        self.initialUsername = initialUsername
 907        self.initialPermissions = initialPermissions
 908        self.onSave = onSave
 909        _username = State(initialValue: initialUsername)
 910        _browse = State(initialValue: initialPermissions.browse)
 911        _submit = State(initialValue: initialPermissions.submit)
 912        _comment = State(initialValue: initialPermissions.comment)
 913        _edit = State(initialValue: initialPermissions.edit)
 914        _triage = State(initialValue: initialPermissions.triage)
 915    }
 916
 917    var body: some View {
 918        NavigationStack {
 919            Form {
 920                Section("User") {
 921                    TextField("Username or ~username", text: $username)
 922                        .autocorrectionDisabled()
 923                        .textInputAutocapitalization(.never)
 924                        .themedRow()
 925                }
 926
 927                permissionSection
 928
 929                if let error, !error.isEmpty {
 930                    Section {
 931                        Text(error)
 932                            .foregroundStyle(.red)
 933                            .themedRow()
 934                    }
 935                }
 936            }
 937            .themedList()
 938            .navigationTitle(title)
 939            .navigationBarTitleDisplayMode(.inline)
 940            .toolbar {
 941                ToolbarItem(placement: .cancellationAction) {
 942                    Button("Cancel") { dismiss() }
 943                }
 944                ToolbarItem(placement: .confirmationAction) {
 945                    Button {
 946                        Task {
 947                            let didSave = await onSave(username, permissions)
 948                            if didSave {
 949                                dismiss()
 950                            }
 951                        }
 952                    } label: {
 953                        if isSaving {
 954                            ProgressView()
 955                                .controlSize(.small)
 956                        } else {
 957                            Text(submitTitle)
 958                        }
 959                    }
 960                    .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
 961                }
 962            }
 963        }
 964    }
 965
 966    private var permissionSection: some View {
 967        Section("Permissions") {
 968            Toggle("Browse", isOn: $browse)
 969                .themedRow()
 970            Toggle("Submit", isOn: $submit)
 971                .themedRow()
 972            Toggle("Comment", isOn: $comment)
 973                .themedRow()
 974            Toggle("Edit", isOn: $edit)
 975                .themedRow()
 976            Toggle("Triage", isOn: $triage)
 977                .themedRow()
 978        }
 979    }
 980
 981    private var permissions: TrackerACLPermissions {
 982        TrackerACLPermissions(
 983            browse: browse,
 984            submit: submit,
 985            comment: comment,
 986            edit: edit,
 987            triage: triage
 988        )
 989    }
 990}
 991
 992private struct TrackerDefaultACLEditorSheet: View {
 993    let isSaving: Bool
 994    let error: String?
 995    let initialPermissions: TrackerACLPermissions
 996    let onSave: (TrackerACLPermissions) async -> Bool
 997
 998    @Environment(\.dismiss) private var dismiss
 999    @State private var browse: Bool
1000    @State private var submit: Bool
1001    @State private var comment: Bool
1002    @State private var edit: Bool
1003    @State private var triage: Bool
1004
1005    init(
1006        isSaving: Bool,
1007        error: String?,
1008        initialPermissions: TrackerACLPermissions,
1009        onSave: @escaping (TrackerACLPermissions) async -> Bool
1010    ) {
1011        self.isSaving = isSaving
1012        self.error = error
1013        self.initialPermissions = initialPermissions
1014        self.onSave = onSave
1015        _browse = State(initialValue: initialPermissions.browse)
1016        _submit = State(initialValue: initialPermissions.submit)
1017        _comment = State(initialValue: initialPermissions.comment)
1018        _edit = State(initialValue: initialPermissions.edit)
1019        _triage = State(initialValue: initialPermissions.triage)
1020    }
1021
1022    var body: some View {
1023        NavigationStack {
1024            Form {
1025                Section("Permissions") {
1026                    Toggle("Browse", isOn: $browse)
1027                        .themedRow()
1028                    Toggle("Submit", isOn: $submit)
1029                        .themedRow()
1030                    Toggle("Comment", isOn: $comment)
1031                        .themedRow()
1032                    Toggle("Edit", isOn: $edit)
1033                        .themedRow()
1034                    Toggle("Triage", isOn: $triage)
1035                        .themedRow()
1036                }
1037
1038                if let error, !error.isEmpty {
1039                    Section {
1040                        Text(error)
1041                            .foregroundStyle(.red)
1042                            .themedRow()
1043                    }
1044                }
1045            }
1046            .themedList()
1047            .navigationTitle("Default ACL")
1048            .navigationBarTitleDisplayMode(.inline)
1049            .toolbar {
1050                ToolbarItem(placement: .cancellationAction) {
1051                    Button("Cancel") { dismiss() }
1052                }
1053                ToolbarItem(placement: .confirmationAction) {
1054                    Button {
1055                        Task {
1056                            let didSave = await onSave(
1057                                TrackerACLPermissions(
1058                                    browse: browse,
1059                                    submit: submit,
1060                                    comment: comment,
1061                                    edit: edit,
1062                                    triage: triage
1063                                )
1064                            )
1065                            if didSave {
1066                                dismiss()
1067                            }
1068                        }
1069                    } label: {
1070                        if isSaving {
1071                            ProgressView()
1072                                .controlSize(.small)
1073                        } else {
1074                            Text("Save")
1075                        }
1076                    }
1077                    .disabled(isSaving)
1078                }
1079            }
1080        }
1081    }
1082}
1083
1084struct TrackerLabelManagementSheet: View {
1085    let viewModel: TrackerManagementViewModel
1086
1087    @Bindable private var bindableViewModel: TrackerManagementViewModel
1088    @State private var showCreateLabel = false
1089    @State private var editingLabel: TicketLabel?
1090    @State private var pendingDeletion: TicketLabel?
1091
1092    init(viewModel: TrackerManagementViewModel) {
1093        self.viewModel = viewModel
1094        self._bindableViewModel = Bindable(viewModel)
1095    }
1096
1097    var body: some View {
1098        NavigationStack {
1099            List {
1100                Section {
1101                    Text("Labels are managed here and reused throughout the tracker.")
1102                        .font(.footnote)
1103                        .foregroundStyle(.secondary)
1104                        .themedRow()
1105                }
1106
1107                if viewModel.isLoadingLabels {
1108                    HStack {
1109                        Spacer()
1110                        ProgressView()
1111                        Spacer()
1112                    }
1113                    .themedRow()
1114                } else if viewModel.labels.isEmpty {
1115                    ContentUnavailableView(
1116                        "No Labels",
1117                        systemImage: "tag",
1118                        description: Text("Create labels for triage and organization.")
1119                    )
1120                    .themedRow()
1121                } else {
1122                    ForEach(viewModel.labels) { label in
1123                        Button {
1124                            editingLabel = label
1125                        } label: {
1126                            TrackerLabelManagementRow(label: label)
1127                        }
1128                        .buttonStyle(.plain)
1129                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
1130                            Button {
1131                                editingLabel = label
1132                            } label: {
1133                                Label("Edit", systemImage: "pencil")
1134                            }
1135                            .tint(.blue)
1136
1137                            Button {
1138                                pendingDeletion = label
1139                            } label: {
1140                                Label("Delete", systemImage: "trash")
1141                            }
1142                            .tint(.red)
1143                        }
1144                    }
1145                    .themedRow()
1146                }
1147            }
1148            .themedList()
1149            .navigationTitle("Labels")
1150            .navigationBarTitleDisplayMode(.inline)
1151            .toolbar {
1152                ToolbarItem(placement: .topBarTrailing) {
1153                    Button {
1154                        showCreateLabel = true
1155                    } label: {
1156                        Image(systemName: "plus")
1157                    }
1158                    .disabled(viewModel.isSavingLabel)
1159                    .accessibilityLabel("Create label")
1160                }
1161            }
1162            .task {
1163                await viewModel.loadLabels()
1164            }
1165            .srhtErrorBanner(error: $bindableViewModel.error)
1166            .sheet(isPresented: $showCreateLabel) {
1167                TrackerLabelEditorSheet(
1168                    title: "New Label",
1169                    submitTitle: "Create",
1170                    isSaving: viewModel.isSavingLabel,
1171                    error: viewModel.error,
1172                    initialLabel: nil
1173                ) { name, foreground, background in
1174                    await viewModel.createLabel(
1175                        name: name,
1176                        foregroundColor: foreground,
1177                        backgroundColor: background
1178                    )
1179                }
1180            }
1181            .sheet(item: $editingLabel) { label in
1182                TrackerLabelEditorSheet(
1183                    title: "Update Label",
1184                    submitTitle: "Save",
1185                    isSaving: viewModel.isSavingLabel,
1186                    error: viewModel.error,
1187                    initialLabel: label
1188                ) { name, foreground, background in
1189                    await viewModel.updateLabel(
1190                        label,
1191                        name: name,
1192                        foregroundColor: foreground,
1193                        backgroundColor: background
1194                    )
1195                }
1196            }
1197            .alert("Delete Label?", isPresented: Binding(
1198                get: { pendingDeletion != nil },
1199                set: { isPresented in
1200                    if !isPresented {
1201                        pendingDeletion = nil
1202                    }
1203                }
1204            )) {
1205                Button("Cancel", role: .cancel) {
1206                    // no-op: .cancel role handles alert dismissal
1207                }
1208                Button("Delete", role: .destructive) {
1209                    guard let pendingDeletion else { return }
1210                    Task {
1211                        await viewModel.deleteLabel(pendingDeletion)
1212                        self.pendingDeletion = nil
1213                    }
1214                }
1215            } message: {
1216                if let pendingDeletion {
1217                    Text("\(pendingDeletion.name)” will be removed from this tracker and from any tickets using it.")
1218                }
1219            }
1220        }
1221    }
1222}
1223
1224private struct TrackerLabelManagementRow: View {
1225    let label: TicketLabel
1226
1227    var body: some View {
1228        VStack(alignment: .leading, spacing: 8) {
1229            HStack(alignment: .top, spacing: 12) {
1230                LabelPill(label: label)
1231                Spacer()
1232                Image(systemName: "chevron.right")
1233                    .font(.caption.weight(.semibold))
1234                    .foregroundStyle(.tertiary)
1235            }
1236
1237            HStack(spacing: 12) {
1238                colorSwatch(hex: label.backgroundColor, title: "Background")
1239                colorSwatch(hex: label.foregroundColor, title: "Text")
1240            }
1241        }
1242        .padding(.vertical, 4)
1243    }
1244
1245    private func colorSwatch(hex: String, title: String) -> some View {
1246        HStack(spacing: 6) {
1247            Circle()
1248                .fill(Color(hex: hex) ?? .clear)
1249                .frame(width: 10, height: 10)
1250                .overlay {
1251                    Circle()
1252                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
1253                }
1254
1255            Text("\(title): \(hex.uppercased())")
1256                .font(.caption)
1257                .foregroundStyle(.secondary)
1258        }
1259    }
1260}
1261
1262private struct TrackerLabelEditorSheet: View {
1263    let title: String
1264    let submitTitle: String
1265    let isSaving: Bool
1266    let error: String?
1267    let initialLabel: TicketLabel?
1268    let onSave: (String, String, String) async -> Bool
1269
1270    @Environment(\.dismiss) private var dismiss
1271    @State private var name: String
1272    @State private var foregroundColor: Color
1273    @State private var backgroundColor: Color
1274
1275    init(
1276        title: String,
1277        submitTitle: String,
1278        isSaving: Bool,
1279        error: String?,
1280        initialLabel: TicketLabel?,
1281        onSave: @escaping (String, String, String) async -> Bool
1282    ) {
1283        self.title = title
1284        self.submitTitle = submitTitle
1285        self.isSaving = isSaving
1286        self.error = error
1287        self.initialLabel = initialLabel
1288        self.onSave = onSave
1289        _name = State(initialValue: initialLabel?.name ?? "")
1290        _foregroundColor = State(initialValue: Color(hex: initialLabel?.foregroundColor ?? "#ffffff") ?? .white)
1291        _backgroundColor = State(initialValue: Color(hex: initialLabel?.backgroundColor ?? "#000000") ?? .black)
1292    }
1293
1294    var body: some View {
1295        NavigationStack {
1296            Form {
1297                Section("Details") {
1298                    TextField("Label name", text: $name)
1299                        .themedRow()
1300                    ColorPicker("Foreground", selection: $foregroundColor, supportsOpacity: false)
1301                        .themedRow()
1302                    ColorPicker("Background", selection: $backgroundColor, supportsOpacity: false)
1303                        .themedRow()
1304                }
1305
1306                Section("Preview") {
1307                    LabelPill(
1308                        label: TicketLabel(
1309                            id: initialLabel?.id ?? -1,
1310                            name: name.isEmpty ? "Preview" : name,
1311                            backgroundColor: backgroundColor.hexString,
1312                            foregroundColor: foregroundColor.hexString
1313                        )
1314                    )
1315                    .themedRow()
1316                }
1317
1318                if let error, !error.isEmpty {
1319                    Section {
1320                        Text(error)
1321                            .foregroundStyle(.red)
1322                            .themedRow()
1323                    }
1324                }
1325            }
1326            .themedList()
1327            .navigationTitle(title)
1328            .navigationBarTitleDisplayMode(.inline)
1329            .toolbar {
1330                ToolbarItem(placement: .cancellationAction) {
1331                    Button("Cancel") { dismiss() }
1332                }
1333                ToolbarItem(placement: .confirmationAction) {
1334                    Button {
1335                        Task {
1336                            let didSave = await onSave(
1337                                name,
1338                                foregroundColor.hexString,
1339                                backgroundColor.hexString
1340                            )
1341                            if didSave {
1342                                dismiss()
1343                            }
1344                        }
1345                    } label: {
1346                        if isSaving {
1347                            ProgressView()
1348                                .controlSize(.small)
1349                        } else {
1350                            Text(submitTitle)
1351                        }
1352                    }
1353                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
1354                }
1355            }
1356        }
1357    }
1358}