krz/hutch

an ios client for sourcehut

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

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