krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.0: 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}