krz/hutch

an ios client for sourcehut

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

v2.12.0: Hutch/Views/Repositories/RepositorySettingsView.swift · raw

  1import SwiftUI
  2
  3struct RepositorySettingsView: View {
  4    let repository: RepositorySummary
  5    let branches: [ReferenceDetail]
  6    let client: SRHTClient
  7    let onRenamed: (String) -> Void
  8    let onDeleted: () -> Void
  9
 10    @Environment(\.dismiss) private var dismiss
 11    @State private var viewModel: RepositorySettingsViewModel?
 12    @State private var showDeleteConfirmation = false
 13    @State private var showRenameConfirmation = false
 14    @State private var pendingACLDeletion: ACLEntry?
 15    @State private var saveResultAlert: SaveResultAlert?
 16
 17    var body: some View {
 18        NavigationStack {
 19            Group {
 20                if let viewModel {
 21                    settingsForm(viewModel)
 22                } else {
 23                    SRHTLoadingStateView(message: "Loading settings…")
 24                }
 25            }
 26            .navigationTitle("Settings")
 27            .navigationBarTitleDisplayMode(.inline)
 28            .toolbar {
 29                ToolbarItem(placement: .cancellationAction) {
 30                    Button("Done") { dismiss() }
 31                }
 32            }
 33        }
 34        .task {
 35            if viewModel == nil {
 36                let vm = RepositorySettingsViewModel(
 37                    repository: repository,
 38                    branches: branches,
 39                    client: client
 40                )
 41                viewModel = vm
 42                await vm.loadACLs()
 43            }
 44        }
 45    }
 46
 47    @ViewBuilder
 48    private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
 49        @Bindable var vm = viewModel
 50
 51        Form {
 52            infoSection(viewModel)
 53            renameSection(viewModel)
 54            accessSection(viewModel)
 55            deleteSection(viewModel)
 56        }
 57        .srhtErrorBanner(error: $vm.error)
 58        .alert(
 59            "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?",
 60            isPresented: $showRenameConfirmation
 61        ) {
 62            Button("Cancel", role: .cancel) {
 63                // Alert dismissal is implicit; no additional action required.
 64            }
 65            Button("Rename", role: .destructive) {
 66                Task {
 67                    await viewModel.rename()
 68                    if let newName = viewModel.updatedName {
 69                        onRenamed(newName)
 70                        dismiss()
 71                    }
 72                }
 73            }
 74        } message: {
 75            Text("This will change the repository URL. Existing clones will be redirected but links may break.")
 76        }
 77        .alert(
 78            "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
 79            isPresented: $showDeleteConfirmation
 80        ) {
 81            Button("Cancel", role: .cancel) {
 82                // Alert dismissal is implicit; no additional action required.
 83            }
 84            Button("Delete", role: .destructive) {
 85                Task {
 86                    await viewModel.deleteRepository()
 87                    if viewModel.didDelete {
 88                        dismiss()
 89                        onDeleted()
 90                    }
 91                }
 92            }
 93        } message: {
 94            Text("This cannot be undone.")
 95        }
 96        .alert("Remove Access?", isPresented: Binding(
 97            get: { pendingACLDeletion != nil },
 98            set: { isPresented in
 99                if !isPresented {
100                    pendingACLDeletion = nil
101                }
102            }
103        )) {
104            Button("Cancel", role: .cancel) {
105                // Alert dismissal is implicit; no additional action required.
106            }
107            Button("Remove Access", role: .destructive) {
108                guard let entry = pendingACLDeletion else { return }
109                Task {
110                    await viewModel.deleteACL(entry)
111                    pendingACLDeletion = nil
112                }
113            }
114        } message: {
115            if let entry = pendingACLDeletion {
116                Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
117            }
118        }
119        .alert(item: $saveResultAlert) { alert in
120            Alert(
121                title: Text(alert.title),
122                message: Text(alert.message),
123                dismissButton: .default(Text("OK"))
124            )
125        }
126    }
127
128    // MARK: - Info Section
129
130    @ViewBuilder
131    private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
132        Section("Info") {
133            LabeledContent("Name") {
134                Text(repository.name)
135                    .font(.body.monospaced())
136            }
137
138            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
139                .lineLimit(3...6)
140
141            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
142                Text("Public").tag(Visibility.public)
143                Text("Unlisted").tag(Visibility.unlisted)
144                Text("Private").tag(Visibility.private)
145            }
146
147            if !viewModel.branches.isEmpty {
148                Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
149                    ForEach(viewModel.branches, id: \.name) { branch in
150                        let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
151                        Text(name).tag(name)
152                    }
153                }
154            }
155
156            Button {
157                Task {
158                    let didSave = await viewModel.saveInfo()
159                    saveResultAlert = SaveResultAlert(
160                        title: didSave ? "Settings Updated" : "Couldn't Update Settings",
161                        message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
162                    )
163                }
164            } label: {
165                if viewModel.isSavingInfo {
166                    ProgressView()
167                        .frame(maxWidth: .infinity)
168                } else {
169                    Text("Save Changes")
170                        .frame(maxWidth: .infinity)
171                }
172            }
173            .disabled(viewModel.isSavingInfo)
174        }
175    }
176
177    // MARK: - Rename Section
178
179    @ViewBuilder
180    private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
181        Section {
182            TextField("New repository name", text: Bindable(viewModel).editedName)
183                .autocorrectionDisabled()
184                .textInputAutocapitalization(.never)
185
186            Text("This will change the repository URL. Existing clones will be redirected but links may break.")
187                .font(.caption)
188                .foregroundStyle(.secondary)
189
190            Button {
191                showRenameConfirmation = true
192            } label: {
193                if viewModel.isRenaming {
194                    ProgressView()
195                        .frame(maxWidth: .infinity)
196                } else {
197                    Text("Rename Repository")
198                        .frame(maxWidth: .infinity)
199                }
200            }
201            .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
202        } header: {
203            Text("Rename")
204        }
205    }
206
207    // MARK: - Access Section
208
209    @ViewBuilder
210    private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
211        Section {
212            if viewModel.isLoadingACLs {
213                HStack {
214                    Spacer()
215                    ProgressView()
216                    Spacer()
217                }
218            } else if viewModel.acls.isEmpty {
219                Text("No access entries yet.")
220                    .foregroundStyle(.secondary)
221            } else {
222                ForEach(viewModel.acls) { entry in
223                    HStack {
224                        Text(entry.entity.canonicalName)
225                        Spacer()
226                        Text(entry.mode)
227                            .font(.caption.monospaced())
228                            .foregroundStyle(.secondary)
229                    }
230                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
231                        Button(role: .destructive) {
232                            pendingACLDeletion = entry
233                        } label: {
234                            Label("Remove Access", systemImage: "trash")
235                        }
236                    }
237                }
238            }
239
240            // Add ACL form
241            HStack {
242                TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
243                    .autocorrectionDisabled()
244                    .textInputAutocapitalization(.never)
245
246                Picker("", selection: Bindable(viewModel).newACLMode) {
247                    Text("RO").tag("RO")
248                    Text("RW").tag("RW")
249                }
250                .pickerStyle(.segmented)
251                .frame(width: 100)
252
253                Button {
254                    Task { await viewModel.addACL() }
255                } label: {
256                    if viewModel.isAddingACL {
257                        ProgressView()
258                    } else {
259                        Text("Add")
260                    }
261                }
262                .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
263            }
264            Text("Add a SourceHut user and choose read-only or read/write access.")
265                .font(.caption)
266                .foregroundStyle(.secondary)
267        } header: {
268            Text("Access")
269        }
270    }
271
272    // MARK: - Delete Section
273
274    @ViewBuilder
275    private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
276        Section {
277            Button(role: .destructive) {
278                showDeleteConfirmation = true
279            } label: {
280                if viewModel.isDeleting {
281                    ProgressView()
282                        .frame(maxWidth: .infinity)
283                } else {
284                    Text("Delete Repository")
285                        .frame(maxWidth: .infinity)
286                }
287            }
288            .disabled(viewModel.isDeleting)
289        }
290    }
291
292    private struct SaveResultAlert: Identifiable {
293        let title: String
294        let message: String
295
296        var id: String { "\(title)-\(message)" }
297    }
298}