krz/hutch

an ios client for sourcehut

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

v2.15.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    @State private var saveResultAlert: SaveResultAlert?
 13
 14    var body: some View {
 15        NavigationStack {
 16            Group {
 17                if let viewModel {
 18                    settingsForm(viewModel)
 19                } else {
 20                    SRHTLoadingStateView(message: "Loading settings…")
 21                }
 22            }
 23            .navigationTitle("Settings")
 24            .navigationBarTitleDisplayMode(.inline)
 25            .toolbar {
 26                ToolbarItem(placement: .cancellationAction) {
 27                    Button("Done") { dismiss() }
 28                }
 29            }
 30        }
 31        .task {
 32            if viewModel == nil {
 33                let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
 34                viewModel = vm
 35                async let info: () = vm.loadRepositoryInfo()
 36                async let acls: () = vm.loadACLs()
 37                _ = await (info, acls)
 38            }
 39        }
 40    }
 41
 42    @ViewBuilder
 43    private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
 44        @Bindable var vm = viewModel
 45
 46        Form {
 47            infoSection(viewModel)
 48            accessSection(viewModel)
 49            featuresSection(viewModel)
 50            histeditSection(viewModel)
 51            deleteSection(viewModel)
 52        }
 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        .alert(item: $saveResultAlert) { alert in
 97            Alert(
 98                title: Text(alert.title),
 99                message: Text(alert.message),
100                dismissButton: .default(Text("OK"))
101            )
102        }
103    }
104
105    @ViewBuilder
106    private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
107        Section("Info") {
108            LabeledContent("Name") {
109                Text(repository.name)
110                    .font(.body.monospaced())
111            }
112
113            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
114                .lineLimit(3...6)
115
116            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
117                Text("Public").tag(Visibility.public)
118                Text("Unlisted").tag(Visibility.unlisted)
119                Text("Private").tag(Visibility.private)
120            }
121
122            Button {
123                Task {
124                    let didSave = await viewModel.saveInfo()
125                    saveResultAlert = SaveResultAlert(
126                        title: didSave ? "Settings Updated" : "Couldn't Update Settings",
127                        message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
128                    )
129                }
130            } label: {
131                if viewModel.isSavingInfo {
132                    ProgressView()
133                        .frame(maxWidth: .infinity)
134                } else {
135                    Text("Save Changes")
136                        .frame(maxWidth: .infinity)
137                }
138            }
139            .disabled(viewModel.isSavingInfo)
140        }
141    }
142
143    @ViewBuilder
144    private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
145        Section("Access") {
146            if viewModel.isLoadingACLs {
147                HStack {
148                    Spacer()
149                    ProgressView()
150                    Spacer()
151                }
152            } else if viewModel.acls.isEmpty {
153                Text("No access entries yet.")
154                    .foregroundStyle(.secondary)
155            } else {
156                ForEach(viewModel.acls) { entry in
157                    HStack {
158                        Text(entry.entity.canonicalName)
159                        Spacer()
160                        Text(entry.mode)
161                            .font(.caption.monospaced())
162                            .foregroundStyle(.secondary)
163                    }
164                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
165                        Button(role: .destructive) {
166                            pendingACLDeletion = entry
167                        } label: {
168                            Label("Remove Access", systemImage: "trash")
169                        }
170                    }
171                }
172            }
173
174            HStack {
175                TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
176                    .autocorrectionDisabled()
177                    .textInputAutocapitalization(.never)
178
179                Picker("", selection: Bindable(viewModel).newACLMode) {
180                    Text("RO").tag("RO")
181                    Text("RW").tag("RW")
182                }
183                .pickerStyle(.segmented)
184                .frame(width: 100)
185
186                Button {
187                    Task { await viewModel.addACL() }
188                } label: {
189                    if viewModel.isAddingACL {
190                        ProgressView()
191                    } else {
192                        Text("Add")
193                    }
194                }
195                .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
196            }
197            Text("Add a SourceHut user and choose read-only or read/write access.")
198                .font(.caption)
199                .foregroundStyle(.secondary)
200        }
201    }
202
203    @ViewBuilder
204    private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
205        Section("Features") {
206            Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
207
208            Button {
209                Task {
210                    let didSave = await viewModel.saveInfo()
211                    saveResultAlert = SaveResultAlert(
212                        title: didSave ? "Settings Updated" : "Couldn't Update Settings",
213                        message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
214                    )
215                }
216            } label: {
217                if viewModel.isSavingInfo {
218                    ProgressView()
219                        .frame(maxWidth: .infinity)
220                } else {
221                    Text("Save Changes")
222                        .frame(maxWidth: .infinity)
223                }
224            }
225            .disabled(viewModel.isSavingInfo)
226        }
227    }
228
229    @ViewBuilder
230    private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
231        Section("Histedit") {
232            TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
233                .autocorrectionDisabled()
234                .textInputAutocapitalization(.never)
235                .disabled(true)
236
237            Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
238                .font(.caption)
239                .foregroundStyle(.secondary)
240
241            Button("Remove Revision", role: .destructive) {
242                // Not implemented: the hg.sr.ht API does not expose a histedit endpoint.
243                // This button is disabled until the API supports revision removal.
244            }
245                .disabled(true)
246        }
247    }
248
249    @ViewBuilder
250    private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
251        Section {
252            Button(role: .destructive) {
253                showDeleteConfirmation = true
254            } label: {
255                if viewModel.isDeleting {
256                    ProgressView()
257                        .frame(maxWidth: .infinity)
258                } else {
259                    Text("Delete Repository")
260                        .frame(maxWidth: .infinity)
261                }
262            }
263            .disabled(viewModel.isDeleting)
264        }
265    }
266
267    private struct SaveResultAlert: Identifiable {
268        let title: String
269        let message: String
270
271        var id: String { "\(title)-\(message)" }
272    }
273}