krz/hutch

an ios client for sourcehut

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

v3.0.4: Hutch/Views/Repositories/RepositoryACLView.swift · raw

  1import SwiftUI
  2
  3struct RepositoryACLView: View {
  4    let repository: RepositorySummary
  5    let client: SRHTClient
  6    let showsDoneButton: Bool
  7
  8    @Environment(\.dismiss) private var dismiss
  9    @State private var viewModel: RepositoryACLViewModel?
 10    @State private var pendingDeletion: RepositoryACLEntry?
 11    @State private var showAddSheet = false
 12
 13    var body: some View {
 14        Group {
 15            if let viewModel {
 16                content(viewModel)
 17            } else {
 18                SRHTLoadingStateView(message: "Loading access…")
 19            }
 20        }
 21        .navigationTitle("Access")
 22        .navigationBarTitleDisplayMode(.inline)
 23        .toolbar {
 24            if showsDoneButton {
 25                ToolbarItem(placement: .cancellationAction) {
 26                    Button("Done") { dismiss() }
 27                }
 28            }
 29
 30            if viewModel != nil {
 31                ToolbarItem(placement: .primaryAction) {
 32                    Button {
 33                        showAddSheet = true
 34                    } label: {
 35                        Image(systemName: "plus")
 36                    }
 37                    .accessibilityLabel("Add User")
 38                }
 39            }
 40        }
 41        .task {
 42            if viewModel == nil {
 43                let service = RepositoryACLService(client: client, service: repository.service)
 44                let vm = RepositoryACLViewModel(repository: repository, service: service)
 45                viewModel = vm
 46                await vm.load()
 47            }
 48        }
 49    }
 50
 51    @ViewBuilder
 52    private func content(_ viewModel: RepositoryACLViewModel) -> some View {
 53        @Bindable var vm = viewModel
 54
 55        Group {
 56            if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
 57                SRHTLoadingStateView(message: "Loading access…")
 58            } else if let loadError = viewModel.loadError, !viewModel.hasEntries {
 59                SRHTErrorStateView(
 60                    title: "Couldn't Load Access",
 61                    message: loadError,
 62                    retryAction: { await viewModel.load() }
 63                )
 64            } else {
 65                List {
 66                    if viewModel.visibleEntries.isEmpty {
 67                        ContentUnavailableView {
 68                            Label("No Additional Access", systemImage: "person.2.slash")
 69                        } description: {
 70                            Text("Only the repository owner currently has access.")
 71                        }
 72                        .frame(maxWidth: .infinity)
 73                        .listRowBackground(Color.clear)
 74                    } else {
 75                        Section {
 76                            ForEach(viewModel.visibleEntries) { entry in
 77                                RepositoryACLEntryRow(
 78                                    entry: entry,
 79                                    isUpdating: viewModel.isUpdating(entry),
 80                                    isDeleting: viewModel.isDeleting(entry),
 81                                    onSelectMode: { mode in
 82                                        Task { await viewModel.updatePermission(for: entry, to: mode) }
 83                                    },
 84                                    onDelete: {
 85                                        pendingDeletion = entry
 86                                    }
 87                                )
 88                            }
 89                        }
 90                    }
 91                }
 92                .listStyle(.insetGrouped)
 93                .refreshable {
 94                    await viewModel.load()
 95                }
 96            }
 97        }
 98        .srhtErrorBanner(error: $vm.error)
 99        .alert("Remove Access?", isPresented: Binding(
100            get: { pendingDeletion != nil },
101            set: { isPresented in
102                if !isPresented {
103                    pendingDeletion = nil
104                }
105            }
106        )) {
107            Button("Cancel", role: .cancel) {}
108            Button("Remove Access", role: .destructive) {
109                guard let entry = pendingDeletion else { return }
110                Task {
111                    await viewModel.removeEntry(entry)
112                    pendingDeletion = nil
113                }
114            }
115        } message: {
116            if let entry = pendingDeletion {
117                Text("\(entry.entity.canonicalName) will lose \(entry.mode.displayName.lowercased()) access to this repository.")
118            }
119        }
120        .sheet(isPresented: $showAddSheet) {
121            NavigationStack {
122                RepositoryACLAddUserView(viewModel: viewModel) {
123                    showAddSheet = false
124                }
125            }
126        }
127    }
128}
129
130private struct RepositoryACLEntryRow: View {
131    let entry: RepositoryACLEntry
132    let isUpdating: Bool
133    let isDeleting: Bool
134    let onSelectMode: (AccessMode) -> Void
135    let onDelete: () -> Void
136
137    var body: some View {
138        HStack(alignment: .center, spacing: 12) {
139            Text(entry.entity.canonicalName)
140                .font(.body.monospaced())
141                .lineLimit(2)
142                .truncationMode(.middle)
143                .frame(maxWidth: .infinity, alignment: .leading)
144
145            if isUpdating || isDeleting {
146                ProgressView()
147                    .controlSize(.small)
148            }
149
150            Menu {
151                ForEach(AccessMode.allCases, id: \.self) { mode in
152                    Button {
153                        onSelectMode(mode)
154                    } label: {
155                        if mode == entry.mode {
156                            Label(mode.displayName, systemImage: "checkmark")
157                        } else {
158                            Text(mode.displayName)
159                        }
160                    }
161                }
162            } label: {
163                Text(entry.mode.shortLabel)
164                    .font(.caption.monospaced())
165                    .foregroundStyle(.secondary)
166                    .padding(.horizontal, 10)
167                    .padding(.vertical, 6)
168                    .background(.quaternary, in: Capsule())
169            }
170            .disabled(isUpdating || isDeleting)
171        }
172        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
173            Button(role: .destructive) {
174                onDelete()
175            } label: {
176                Label("Remove", systemImage: "trash")
177            }
178            .disabled(isUpdating || isDeleting)
179        }
180    }
181}
182
183private struct RepositoryACLAddUserView: View {
184    @Environment(\.dismiss) private var dismiss
185
186    @Bindable var viewModel: RepositoryACLViewModel
187    let onAdded: () -> Void
188
189    var body: some View {
190        Form {
191            Section("User") {
192                TextField("Username or ~username", text: $viewModel.addUsername)
193                    .autocorrectionDisabled()
194                    .textInputAutocapitalization(.never)
195
196                if let validation = inlineValidationMessage {
197                    Text(validation)
198                        .font(.caption)
199                        .foregroundStyle(.secondary)
200                }
201            }
202
203            Section("Permission") {
204                Picker("Permission", selection: $viewModel.addMode) {
205                    ForEach(AccessMode.allCases, id: \.self) { mode in
206                        Text(mode.shortLabel).tag(mode)
207                    }
208                }
209                .pickerStyle(.segmented)
210            }
211        }
212        .navigationTitle("Add User")
213        .navigationBarTitleDisplayMode(.inline)
214        .toolbar {
215            ToolbarItem(placement: .cancellationAction) {
216                Button("Cancel") {
217                    dismiss()
218                }
219            }
220
221            ToolbarItem(placement: .confirmationAction) {
222                Button("Add") {
223                    Task {
224                        if await viewModel.addEntry() {
225                            onAdded()
226                        }
227                    }
228                }
229                .disabled(!viewModel.canSubmitNewEntry)
230            }
231        }
232    }
233
234    private var inlineValidationMessage: String? {
235        let trimmed = viewModel.addUsername.trimmingCharacters(in: .whitespacesAndNewlines)
236        guard !trimmed.isEmpty else { return nil }
237        return viewModel.addValidationMessage
238    }
239}