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}