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