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