krz/hutch

an ios client for sourcehut

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

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