krz/hutch

an ios client for sourcehut

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

v3.2.0: Hutch/Views/Repositories/HgRepositorySettingsView.swift · raw

  1import SwiftUI
  2
  3struct HgRepositorySettingsView: View {
  4    let repository: RepositorySummary
  5    let client: SRHTClient
  6    let onDeleted: () -> Void
  7
  8    @Environment(\.dismiss) private var dismiss
  9    @State private var viewModel: HgRepositorySettingsViewModel?
 10    @State private var showDeleteConfirmation = false
 11    @State private var pendingACLDeletion: HgACLEntry?
 12
 13    var body: some View {
 14        NavigationStack {
 15            Group {
 16                if let viewModel {
 17                    settingsForm(viewModel)
 18                } else {
 19                    SRHTLoadingStateView(message: "Loading settings…")
 20                }
 21            }
 22            .navigationTitle("Settings")
 23            .navigationBarTitleDisplayMode(.inline)
 24            .toolbar {
 25                ToolbarItem(placement: .cancellationAction) {
 26                    Button("Done") { dismiss() }
 27                }
 28            }
 29        }
 30        .task {
 31            if viewModel == nil {
 32                let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
 33                viewModel = vm
 34                async let info: () = vm.loadRepositoryInfo()
 35                async let acls: () = vm.loadACLs()
 36                _ = await (info, acls)
 37            }
 38        }
 39    }
 40
 41    @ViewBuilder
 42    private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
 43        @Bindable var vm = viewModel
 44
 45        Form {
 46            infoSection(viewModel)
 47            accessSection(viewModel)
 48            featuresSection(viewModel)
 49            histeditSection(viewModel)
 50            deleteSection(viewModel)
 51        }
 52        .themedList()
 53        .srhtErrorBanner(error: $vm.error)
 54        .alert(
 55            "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
 56            isPresented: $showDeleteConfirmation
 57        ) {
 58            Button("Cancel", role: .cancel) {
 59                // Alert dismissal is implicit; no additional action required.
 60            }
 61            Button("Delete", role: .destructive) {
 62                Task {
 63                    await viewModel.deleteRepository()
 64                    if viewModel.didDelete {
 65                        dismiss()
 66                        onDeleted()
 67                    }
 68                }
 69            }
 70        } message: {
 71            Text("This cannot be undone.")
 72        }
 73        .alert("Remove Access?", isPresented: Binding(
 74            get: { pendingACLDeletion != nil },
 75            set: { isPresented in
 76                if !isPresented {
 77                    pendingACLDeletion = nil
 78                }
 79            }
 80        )) {
 81            Button("Cancel", role: .cancel) {
 82                // Alert dismissal is implicit; no additional action required.
 83            }
 84            Button("Remove Access", role: .destructive) {
 85                guard let entry = pendingACLDeletion else { return }
 86                Task {
 87                    await viewModel.deleteACL(entry)
 88                    pendingACLDeletion = nil
 89                }
 90            }
 91        } message: {
 92            if let entry = pendingACLDeletion {
 93                Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
 94            }
 95        }
 96    }
 97
 98    @ViewBuilder
 99    private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
100        Section("Current Configuration") {
101            LabeledContent("Repository") {
102                Text("\(repository.owner.canonicalName)/\(repository.name)")
103                    .font(.body.monospaced())
104            }
105            .themedRow()
106
107            LabeledContent("Forge") {
108                Text(repositoryForgeLabel(repository.service))
109            }
110            .themedRow()
111
112            LabeledContent("Visibility") {
113                Text(repositoryVisibilityLabel(viewModel.editedVisibility))
114            }
115            .themedRow()
116        }
117
118        Section("Repository Details") {
119            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
120                .lineLimit(3...6)
121                .themedRow()
122
123            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
124                Text("Public").tag(Visibility.publicVisibility)
125                Text("Unlisted").tag(Visibility.unlisted)
126                Text("Private").tag(Visibility.privateVisibility)
127            }
128            .themedRow()
129
130            Button {
131                Task {
132                    _ = await viewModel.saveInfo()
133                }
134            } label: {
135                if viewModel.isSavingInfo {
136                    ProgressView()
137                        .frame(maxWidth: .infinity)
138                } else {
139                    Text("Save Changes")
140                        .frame(maxWidth: .infinity)
141                }
142            }
143            .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
144            .themedRow()
145        }
146    }
147
148    @ViewBuilder
149    private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
150        Section("Access") {
151            if viewModel.isLoadingACLs {
152                HStack {
153                    Spacer()
154                    ProgressView()
155                    Spacer()
156                }
157                .themedRow()
158            } else if viewModel.acls.isEmpty {
159                Text("No access entries yet.")
160                    .foregroundStyle(.secondary)
161                    .themedRow()
162            } else {
163                ForEach(viewModel.acls) { entry in
164                    HStack {
165                        Text(entry.entity.canonicalName)
166                        Spacer()
167                        Text(entry.mode)
168                            .font(.caption.monospaced())
169                            .foregroundStyle(.secondary)
170                    }
171                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
172                        Button(role: .destructive) {
173                            pendingACLDeletion = entry
174                        } label: {
175                            Label("Remove Access", systemImage: "trash")
176                        }
177                    }
178                }
179                .themedRow()
180            }
181
182            HStack {
183                TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
184                    .autocorrectionDisabled()
185                    .textInputAutocapitalization(.never)
186
187                Picker("", selection: Bindable(viewModel).newACLMode) {
188                    Text("RO").tag("RO")
189                    Text("RW").tag("RW")
190                }
191                .pickerStyle(.segmented)
192                .frame(width: 100)
193
194                Button {
195                    Task { await viewModel.addACL() }
196                } label: {
197                    if viewModel.isAddingACL {
198                        ProgressView()
199                    } else {
200                        Text("Add")
201                    }
202                }
203                .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
204            }
205            .themedRow()
206            Text("Add a SourceHut user and choose read-only or read/write access.")
207                .font(.caption)
208                .foregroundStyle(.secondary)
209                .themedRow()
210        }
211    }
212
213    @ViewBuilder
214    private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
215        Section("Sensitive Settings") {
216            Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
217                .themedRow()
218            Text("Changes stay pending until you save this section.")
219                .font(.caption)
220                .foregroundStyle(.secondary)
221                .themedRow()
222
223            Button {
224                Task {
225                    _ = await viewModel.saveInfo()
226                }
227            } label: {
228                if viewModel.isSavingInfo {
229                    ProgressView()
230                        .frame(maxWidth: .infinity)
231                } else {
232                    Text("Save Changes")
233                        .frame(maxWidth: .infinity)
234                }
235            }
236            .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
237            .themedRow()
238        }
239    }
240
241    @ViewBuilder
242    private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
243        Section("Histedit") {
244            TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
245                .autocorrectionDisabled()
246                .textInputAutocapitalization(.never)
247                .disabled(true)
248                .themedRow()
249
250            Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
251                .font(.caption)
252                .foregroundStyle(.secondary)
253                .themedRow()
254
255            Button("Remove Revision", role: .destructive) {
256                // Not implemented: the hg.sr.ht API does not expose a histedit endpoint.
257                // This button is disabled until the API supports revision removal.
258            }
259                .disabled(true)
260                .themedRow()
261        }
262    }
263
264    @ViewBuilder
265    private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
266        Section {
267            Button(role: .destructive) {
268                showDeleteConfirmation = true
269            } label: {
270                if viewModel.isDeleting {
271                    ProgressView()
272                        .frame(maxWidth: .infinity)
273                } else {
274                    Text("Delete Repository")
275                        .frame(maxWidth: .infinity)
276                }
277            }
278            .disabled(viewModel.isDeleting)
279            .themedRow()
280        } header: {
281            Text("Danger Zone")
282        }
283    }
284}