krz/hutch

an ios client for sourcehut

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

v3.1.11: 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 onUpdated: (RepositorySummary) -> Void
  8    let onDeleted: () -> Void
  9
 10    @Environment(\.dismiss) private var dismiss
 11    @State private var viewModel: RepositorySettingsViewModel?
 12    @State private var showVisibilityConfirmation = false
 13    @State private var showDeleteConfirmation = false
 14
 15    var body: some View {
 16        NavigationStack {
 17            Group {
 18                if let viewModel {
 19                    settingsForm(viewModel)
 20                } else {
 21                    SRHTLoadingStateView(message: "Loading settings…")
 22                }
 23            }
 24            .navigationTitle("Settings")
 25            .navigationBarTitleDisplayMode(.inline)
 26            .toolbar {
 27                ToolbarItem(placement: .cancellationAction) {
 28                    Button("Done") { dismiss() }
 29                }
 30            }
 31        }
 32        .task {
 33            if viewModel == nil {
 34                viewModel = RepositorySettingsViewModel(
 35                    repository: repository,
 36                    branches: branches,
 37                    client: client
 38                )
 39            }
 40        }
 41    }
 42
 43    @ViewBuilder
 44    private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
 45        @Bindable var vm = viewModel
 46
 47        Form {
 48            currentConfigurationSection(viewModel)
 49            metadataSection(viewModel)
 50            defaultBranchSection(viewModel)
 51            visibilitySection(viewModel)
 52            deleteSection(viewModel)
 53        }
 54        .themedList()
 55        .srhtErrorBanner(error: $vm.error)
 56        .alert(
 57            visibilityConfirmationTitle(for: viewModel),
 58            isPresented: $showVisibilityConfirmation
 59        ) {
 60            Button("Cancel", role: .cancel) {
 61                viewModel.editedVisibility = viewModel.repository.visibility
 62            }
 63            Button("Apply", role: .destructive) {
 64                Task {
 65                    if let updatedRepository = await viewModel.updateVisibility() {
 66                        onUpdated(updatedRepository)
 67                    }
 68                }
 69            }
 70        } message: {
 71            Text(visibilityConfirmationMessage(for: viewModel))
 72        }
 73        .alert(
 74            "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?",
 75            isPresented: $showDeleteConfirmation
 76        ) {
 77            Button("Cancel", role: .cancel) {
 78                // Alert dismissal is implicit.
 79            }
 80            Button("Delete", role: .destructive) {
 81                Task {
 82                    await viewModel.deleteRepository()
 83                    if viewModel.didDelete {
 84                        dismiss()
 85                        onDeleted()
 86                    }
 87                }
 88            }
 89        } message: {
 90            Text("This cannot be undone.")
 91        }
 92    }
 93
 94    @ViewBuilder
 95    private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View {
 96        Section("Current Configuration") {
 97            LabeledContent("Repository") {
 98                Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)")
 99                    .font(.body.monospaced())
100            }
101            .themedRow()
102
103            LabeledContent("Default Branch") {
104                Text(viewModel.currentDefaultBranchName)
105                    .font(.body.monospaced())
106            }
107            .themedRow()
108
109            LabeledContent("Visibility") {
110                Text(repositoryVisibilityLabel(viewModel.repository.visibility))
111            }
112            .themedRow()
113        }
114    }
115
116    @ViewBuilder
117    private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View {
118        Section {
119            TextField("Repository name", text: Bindable(viewModel).editedName)
120                .autocorrectionDisabled()
121                .textInputAutocapitalization(.never)
122                .themedRow()
123
124            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
125                .lineLimit(2...4)
126                .themedRow()
127
128            if let metadataValidationMessage = viewModel.metadataValidationMessage {
129                Text(metadataValidationMessage)
130                    .font(.caption)
131                    .foregroundStyle(.red)
132                    .themedRow()
133            } else if viewModel.normalizedEditedName != viewModel.repository.name {
134                Text("Changing the repository name updates the repository URL.")
135                    .font(.caption)
136                    .foregroundStyle(.secondary)
137                    .themedRow()
138            } else {
139                Text("Name and description stay pending until you save this section.")
140                    .font(.caption)
141                    .foregroundStyle(.secondary)
142                    .themedRow()
143            }
144
145            Button {
146                Task {
147                    if let updatedRepository = await viewModel.saveMetadata() {
148                        onUpdated(updatedRepository)
149                    }
150                }
151            } label: {
152                if viewModel.isSavingMetadata {
153                    ProgressView()
154                        .frame(maxWidth: .infinity)
155                } else {
156                    Text("Save Details")
157                        .frame(maxWidth: .infinity)
158                }
159            }
160            .disabled(
161                viewModel.isMutating ||
162                !viewModel.isMetadataDirty ||
163                viewModel.metadataValidationMessage != nil
164            )
165            .themedRow()
166        } header: {
167            Text("Repository Details")
168        }
169    }
170
171    @ViewBuilder
172    private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View {
173        Section {
174            LabeledContent("Current") {
175                Text(viewModel.currentDefaultBranchName)
176                    .font(.body.monospaced())
177            }
178            .themedRow()
179
180            if viewModel.branches.isEmpty {
181                Text("This repository doesn't have any branches yet.")
182                    .foregroundStyle(.secondary)
183                    .themedRow()
184            } else {
185                Picker("Branch", selection: Bindable(viewModel).editedHead) {
186                    ForEach(viewModel.availableBranchNames, id: \.self) { branch in
187                        Text(branch)
188                            .font(.body.monospaced())
189                            .tag(branch)
190                    }
191                }
192                .themedRow()
193
194                Text("Changes stay pending until you set the new default branch.")
195                    .font(.caption)
196                    .foregroundStyle(.secondary)
197                    .themedRow()
198
199                Button {
200                    Task {
201                        if let updatedRepository = await viewModel.saveDefaultBranch() {
202                            onUpdated(updatedRepository)
203                        }
204                    }
205                } label: {
206                    if viewModel.isSavingDefaultBranch {
207                        ProgressView()
208                            .frame(maxWidth: .infinity)
209                    } else {
210                        Text("Set Default Branch")
211                            .frame(maxWidth: .infinity)
212                    }
213                }
214                .disabled(
215                    viewModel.isMutating ||
216                    !viewModel.isDefaultBranchDirty ||
217                    viewModel.defaultBranchValidationMessage != nil
218                )
219                .themedRow()
220            }
221        } header: {
222            Text("Default Branch")
223        }
224    }
225
226    @ViewBuilder
227    private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View {
228        Section {
229            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
230                Text("Public").tag(Visibility.publicVisibility)
231                Text("Unlisted").tag(Visibility.unlisted)
232                Text("Private").tag(Visibility.privateVisibility)
233            }
234            .themedRow()
235
236            Text("Visibility changes apply immediately after you confirm them.")
237                .font(.caption)
238                .foregroundStyle(.secondary)
239                .themedRow()
240
241            Button {
242                showVisibilityConfirmation = true
243            } label: {
244                if viewModel.isUpdatingVisibility {
245                    ProgressView()
246                        .frame(maxWidth: .infinity)
247                } else {
248                    Text("Apply Visibility Change")
249                        .frame(maxWidth: .infinity)
250                }
251            }
252            .disabled(viewModel.isMutating || !viewModel.isVisibilityDirty)
253            .themedRow()
254        } header: {
255            Text("Sensitive Settings")
256        }
257    }
258
259    @ViewBuilder
260    private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
261        Section {
262            Button(role: .destructive) {
263                showDeleteConfirmation = true
264            } label: {
265                if viewModel.isDeleting {
266                    ProgressView()
267                        .frame(maxWidth: .infinity)
268                } else {
269                    Text("Delete Repository")
270                        .frame(maxWidth: .infinity)
271                }
272            }
273            .disabled(viewModel.isMutating)
274            .themedRow()
275        } header: {
276            Text("Danger Zone")
277        }
278    }
279
280    private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String {
281        "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?"
282    }
283
284    private func visibilityConfirmationMessage(for viewModel: RepositorySettingsViewModel) -> String {
285        switch viewModel.editedVisibility {
286        case .publicVisibility:
287            "Anyone will be able to find and view this repository."
288        case .unlisted:
289            "People with the link can view this repository, but it won't appear in public listings."
290        case .privateVisibility:
291            "Only people with explicit access will be able to view this repository."
292        }
293    }
294}