krz/hutch

an ios client for sourcehut

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

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

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