krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.12.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 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 showRenameConfirmation = false
14 @State private var pendingACLDeletion: ACLEntry?
15 @State private var saveResultAlert: SaveResultAlert?
16
17 var body: some View {
18 NavigationStack {
19 Group {
20 if let viewModel {
21 settingsForm(viewModel)
22 } else {
23 SRHTLoadingStateView(message: "Loading settings…")
24 }
25 }
26 .navigationTitle("Settings")
27 .navigationBarTitleDisplayMode(.inline)
28 .toolbar {
29 ToolbarItem(placement: .cancellationAction) {
30 Button("Done") { dismiss() }
31 }
32 }
33 }
34 .task {
35 if viewModel == nil {
36 let vm = RepositorySettingsViewModel(
37 repository: repository,
38 branches: branches,
39 client: client
40 )
41 viewModel = vm
42 await vm.loadACLs()
43 }
44 }
45 }
46
47 @ViewBuilder
48 private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
49 @Bindable var vm = viewModel
50
51 Form {
52 infoSection(viewModel)
53 renameSection(viewModel)
54 accessSection(viewModel)
55 deleteSection(viewModel)
56 }
57 .srhtErrorBanner(error: $vm.error)
58 .alert(
59 "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?",
60 isPresented: $showRenameConfirmation
61 ) {
62 Button("Cancel", role: .cancel) {
63 // Alert dismissal is implicit; no additional action required.
64 }
65 Button("Rename", role: .destructive) {
66 Task {
67 await viewModel.rename()
68 if let newName = viewModel.updatedName {
69 onRenamed(newName)
70 dismiss()
71 }
72 }
73 }
74 } message: {
75 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
76 }
77 .alert(
78 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
79 isPresented: $showDeleteConfirmation
80 ) {
81 Button("Cancel", role: .cancel) {
82 // Alert dismissal is implicit; no additional action required.
83 }
84 Button("Delete", role: .destructive) {
85 Task {
86 await viewModel.deleteRepository()
87 if viewModel.didDelete {
88 dismiss()
89 onDeleted()
90 }
91 }
92 }
93 } message: {
94 Text("This cannot be undone.")
95 }
96 .alert("Remove Access?", isPresented: Binding(
97 get: { pendingACLDeletion != nil },
98 set: { isPresented in
99 if !isPresented {
100 pendingACLDeletion = nil
101 }
102 }
103 )) {
104 Button("Cancel", role: .cancel) {
105 // Alert dismissal is implicit; no additional action required.
106 }
107 Button("Remove Access", role: .destructive) {
108 guard let entry = pendingACLDeletion else { return }
109 Task {
110 await viewModel.deleteACL(entry)
111 pendingACLDeletion = nil
112 }
113 }
114 } message: {
115 if let entry = pendingACLDeletion {
116 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
117 }
118 }
119 .alert(item: $saveResultAlert) { alert in
120 Alert(
121 title: Text(alert.title),
122 message: Text(alert.message),
123 dismissButton: .default(Text("OK"))
124 )
125 }
126 }
127
128 // MARK: - Info Section
129
130 @ViewBuilder
131 private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
132 Section("Info") {
133 LabeledContent("Name") {
134 Text(repository.name)
135 .font(.body.monospaced())
136 }
137
138 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
139 .lineLimit(3...6)
140
141 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
142 Text("Public").tag(Visibility.public)
143 Text("Unlisted").tag(Visibility.unlisted)
144 Text("Private").tag(Visibility.private)
145 }
146
147 if !viewModel.branches.isEmpty {
148 Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
149 ForEach(viewModel.branches, id: \.name) { branch in
150 let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
151 Text(name).tag(name)
152 }
153 }
154 }
155
156 Button {
157 Task {
158 let didSave = await viewModel.saveInfo()
159 saveResultAlert = SaveResultAlert(
160 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
161 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
162 )
163 }
164 } label: {
165 if viewModel.isSavingInfo {
166 ProgressView()
167 .frame(maxWidth: .infinity)
168 } else {
169 Text("Save Changes")
170 .frame(maxWidth: .infinity)
171 }
172 }
173 .disabled(viewModel.isSavingInfo)
174 }
175 }
176
177 // MARK: - Rename Section
178
179 @ViewBuilder
180 private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
181 Section {
182 TextField("New repository name", text: Bindable(viewModel).editedName)
183 .autocorrectionDisabled()
184 .textInputAutocapitalization(.never)
185
186 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
187 .font(.caption)
188 .foregroundStyle(.secondary)
189
190 Button {
191 showRenameConfirmation = true
192 } label: {
193 if viewModel.isRenaming {
194 ProgressView()
195 .frame(maxWidth: .infinity)
196 } else {
197 Text("Rename Repository")
198 .frame(maxWidth: .infinity)
199 }
200 }
201 .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
202 } header: {
203 Text("Rename")
204 }
205 }
206
207 // MARK: - Access Section
208
209 @ViewBuilder
210 private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
211 Section {
212 if viewModel.isLoadingACLs {
213 HStack {
214 Spacer()
215 ProgressView()
216 Spacer()
217 }
218 } else if viewModel.acls.isEmpty {
219 Text("No access entries yet.")
220 .foregroundStyle(.secondary)
221 } else {
222 ForEach(viewModel.acls) { entry in
223 HStack {
224 Text(entry.entity.canonicalName)
225 Spacer()
226 Text(entry.mode)
227 .font(.caption.monospaced())
228 .foregroundStyle(.secondary)
229 }
230 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
231 Button(role: .destructive) {
232 pendingACLDeletion = entry
233 } label: {
234 Label("Remove Access", systemImage: "trash")
235 }
236 }
237 }
238 }
239
240 // Add ACL form
241 HStack {
242 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
243 .autocorrectionDisabled()
244 .textInputAutocapitalization(.never)
245
246 Picker("", selection: Bindable(viewModel).newACLMode) {
247 Text("RO").tag("RO")
248 Text("RW").tag("RW")
249 }
250 .pickerStyle(.segmented)
251 .frame(width: 100)
252
253 Button {
254 Task { await viewModel.addACL() }
255 } label: {
256 if viewModel.isAddingACL {
257 ProgressView()
258 } else {
259 Text("Add")
260 }
261 }
262 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
263 }
264 Text("Add a SourceHut user and choose read-only or read/write access.")
265 .font(.caption)
266 .foregroundStyle(.secondary)
267 } header: {
268 Text("Access")
269 }
270 }
271
272 // MARK: - Delete Section
273
274 @ViewBuilder
275 private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
276 Section {
277 Button(role: .destructive) {
278 showDeleteConfirmation = true
279 } label: {
280 if viewModel.isDeleting {
281 ProgressView()
282 .frame(maxWidth: .infinity)
283 } else {
284 Text("Delete Repository")
285 .frame(maxWidth: .infinity)
286 }
287 }
288 .disabled(viewModel.isDeleting)
289 }
290 }
291
292 private struct SaveResultAlert: Identifiable {
293 let title: String
294 let message: String
295
296 var id: String { "\(title)-\(message)" }
297 }
298}