krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.4.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 .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}