krz/hutch

an ios client for sourcehut

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

v2.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            Button("Delete", role: .destructive) {
 63                Task {
 64                    await viewModel.deleteRepository()
 65                    if viewModel.didDelete {
 66                        dismiss()
 67                        onDeleted()
 68                    }
 69                }
 70            }
 71        } message: {
 72            Text("This cannot be undone.")
 73        }
 74        .alert("Remove Access?", isPresented: Binding(
 75            get: { pendingACLDeletion != nil },
 76            set: { isPresented in
 77                if !isPresented {
 78                    pendingACLDeletion = nil
 79                }
 80            }
 81        )) {
 82            Button("Cancel", role: .cancel) {}
 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        .alert(item: $saveResultAlert) { alert in
 96            Alert(
 97                title: Text(alert.title),
 98                message: Text(alert.message),
 99                dismissButton: .default(Text("OK"))
100            )
101        }
102    }
103
104    // MARK: - Info Section
105
106    @ViewBuilder
107    private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
108        Section("Info") {
109            LabeledContent("Name") {
110                Text(repository.name)
111                    .font(.body.monospaced())
112            }
113
114            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
115                .lineLimit(3...6)
116
117            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
118                Text("Public").tag(Visibility.public)
119                Text("Unlisted").tag(Visibility.unlisted)
120                Text("Private").tag(Visibility.private)
121            }
122
123            if !viewModel.branches.isEmpty {
124                Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
125                    ForEach(viewModel.branches, id: \.name) { branch in
126                        let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
127                        Text(name).tag(name)
128                    }
129                }
130            }
131
132            Button {
133                Task {
134                    let didSave = await viewModel.saveInfo()
135                    saveResultAlert = SaveResultAlert(
136                        title: didSave ? "Settings Updated" : "Couldn't Update Settings",
137                        message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
138                    )
139                }
140            } label: {
141                if viewModel.isSavingInfo {
142                    ProgressView()
143                        .frame(maxWidth: .infinity)
144                } else {
145                    Text("Save Changes")
146                        .frame(maxWidth: .infinity)
147                }
148            }
149            .disabled(viewModel.isSavingInfo)
150        }
151    }
152
153    // MARK: - Rename Section
154
155    @ViewBuilder
156    private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
157        Section {
158            TextField("New repository name", text: Bindable(viewModel).editedName)
159                .autocorrectionDisabled()
160                .textInputAutocapitalization(.never)
161
162            Text("This will change the repository URL. Existing clones will be redirected but links may break.")
163                .font(.caption)
164                .foregroundStyle(.secondary)
165
166            Button {
167                Task {
168                    await viewModel.rename()
169                    if let newName = viewModel.updatedName {
170                        onRenamed(newName)
171                        dismiss()
172                    }
173                }
174            } label: {
175                if viewModel.isRenaming {
176                    ProgressView()
177                        .frame(maxWidth: .infinity)
178                } else {
179                    Text("Rename Repository")
180                        .frame(maxWidth: .infinity)
181                }
182            }
183            .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
184        } header: {
185            Text("Rename")
186        }
187    }
188
189    // MARK: - Access Section
190
191    @ViewBuilder
192    private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
193        Section {
194            if viewModel.isLoadingACLs {
195                HStack {
196                    Spacer()
197                    ProgressView()
198                    Spacer()
199                }
200            } else if viewModel.acls.isEmpty {
201                Text("No access entries yet.")
202                    .foregroundStyle(.secondary)
203            } else {
204                ForEach(viewModel.acls) { entry in
205                    HStack {
206                        Text(entry.entity.canonicalName)
207                        Spacer()
208                        Text(entry.mode)
209                            .font(.caption.monospaced())
210                            .foregroundStyle(.secondary)
211                    }
212                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
213                        Button(role: .destructive) {
214                            pendingACLDeletion = entry
215                        } label: {
216                            Label("Remove Access", systemImage: "trash")
217                        }
218                    }
219                }
220            }
221
222            // Add ACL form
223            HStack {
224                TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
225                    .autocorrectionDisabled()
226                    .textInputAutocapitalization(.never)
227
228                Picker("", selection: Bindable(viewModel).newACLMode) {
229                    Text("RO").tag("RO")
230                    Text("RW").tag("RW")
231                }
232                .pickerStyle(.segmented)
233                .frame(width: 100)
234
235                Button {
236                    Task { await viewModel.addACL() }
237                } label: {
238                    if viewModel.isAddingACL {
239                        ProgressView()
240                    } else {
241                        Text("Add")
242                    }
243                }
244                .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
245            }
246            Text("Add a SourceHut user and choose read-only or read/write access.")
247                .font(.caption)
248                .foregroundStyle(.secondary)
249        } header: {
250            Text("Access")
251        }
252    }
253
254    // MARK: - Delete Section
255
256    @ViewBuilder
257    private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
258        Section {
259            Button(role: .destructive) {
260                showDeleteConfirmation = true
261            } label: {
262                if viewModel.isDeleting {
263                    ProgressView()
264                        .frame(maxWidth: .infinity)
265                } else {
266                    Text("Delete Repository")
267                        .frame(maxWidth: .infinity)
268                }
269            }
270            .disabled(viewModel.isDeleting)
271        }
272    }
273
274    private struct SaveResultAlert: Identifiable {
275        let title: String
276        let message: String
277
278        var id: String { "\(title)-\(message)" }
279    }
280}