krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.3: Hutch/Views/Repositories/HgRepositorySettingsView.swift · raw
1import SwiftUI
2
3struct HgRepositorySettingsView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6 let onDeleted: () -> Void
7
8 @Environment(\.dismiss) private var dismiss
9 @State private var viewModel: HgRepositorySettingsViewModel?
10 @State private var showDeleteConfirmation = false
11 @State private var pendingACLDeletion: HgACLEntry?
12
13 var body: some View {
14 NavigationStack {
15 Group {
16 if let viewModel {
17 settingsForm(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading settings…")
20 }
21 }
22 .navigationTitle("Settings")
23 .navigationBarTitleDisplayMode(.inline)
24 .toolbar {
25 ToolbarItem(placement: .cancellationAction) {
26 Button("Done") { dismiss() }
27 }
28 }
29 }
30 .task {
31 if viewModel == nil {
32 let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
33 viewModel = vm
34 async let info: () = vm.loadRepositoryInfo()
35 async let acls: () = vm.loadACLs()
36 _ = await (info, acls)
37 }
38 }
39 }
40
41 @ViewBuilder
42 private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
43 @Bindable var vm = viewModel
44
45 Form {
46 infoSection(viewModel)
47 accessSection(viewModel)
48 featuresSection(viewModel)
49 histeditSection(viewModel)
50 deleteSection(viewModel)
51 }
52 .themedList()
53 .srhtErrorBanner(error: $vm.error)
54 .alert(
55 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
56 isPresented: $showDeleteConfirmation
57 ) {
58 Button("Cancel", role: .cancel) {
59 // Alert dismissal is implicit; no additional action required.
60 }
61 Button("Delete", role: .destructive) {
62 Task {
63 await viewModel.deleteRepository()
64 if viewModel.didDelete {
65 dismiss()
66 onDeleted()
67 }
68 }
69 }
70 } message: {
71 Text("This cannot be undone.")
72 }
73 .alert("Remove Access?", isPresented: Binding(
74 get: { pendingACLDeletion != nil },
75 set: { isPresented in
76 if !isPresented {
77 pendingACLDeletion = nil
78 }
79 }
80 )) {
81 Button("Cancel", role: .cancel) {
82 // Alert dismissal is implicit; no additional action required.
83 }
84 Button("Remove Access", role: .destructive) {
85 guard let entry = pendingACLDeletion else { return }
86 Task {
87 await viewModel.deleteACL(entry)
88 pendingACLDeletion = nil
89 }
90 }
91 } message: {
92 if let entry = pendingACLDeletion {
93 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
94 }
95 }
96 }
97
98 @ViewBuilder
99 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
100 Section("Current Configuration") {
101 LabeledContent("Repository") {
102 Text("\(repository.owner.canonicalName)/\(repository.name)")
103 .font(.body.monospaced())
104 }
105 .themedRow()
106
107 LabeledContent("Forge") {
108 Text(repositoryForgeLabel(repository.service))
109 }
110 .themedRow()
111
112 LabeledContent("Visibility") {
113 Text(repositoryVisibilityLabel(viewModel.editedVisibility))
114 }
115 .themedRow()
116 }
117
118 Section("Repository Details") {
119 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
120 .lineLimit(3...6)
121 .themedRow()
122
123 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
124 Text("Public").tag(Visibility.public)
125 Text("Unlisted").tag(Visibility.unlisted)
126 Text("Private").tag(Visibility.private)
127 }
128 .themedRow()
129
130 Button {
131 Task {
132 _ = await viewModel.saveInfo()
133 }
134 } label: {
135 if viewModel.isSavingInfo {
136 ProgressView()
137 .frame(maxWidth: .infinity)
138 } else {
139 Text("Save Changes")
140 .frame(maxWidth: .infinity)
141 }
142 }
143 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
144 .themedRow()
145 }
146 }
147
148 @ViewBuilder
149 private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
150 Section("Access") {
151 if viewModel.isLoadingACLs {
152 HStack {
153 Spacer()
154 ProgressView()
155 Spacer()
156 }
157 .themedRow()
158 } else if viewModel.acls.isEmpty {
159 Text("No access entries yet.")
160 .foregroundStyle(.secondary)
161 .themedRow()
162 } else {
163 ForEach(viewModel.acls) { entry in
164 HStack {
165 Text(entry.entity.canonicalName)
166 Spacer()
167 Text(entry.mode)
168 .font(.caption.monospaced())
169 .foregroundStyle(.secondary)
170 }
171 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
172 Button(role: .destructive) {
173 pendingACLDeletion = entry
174 } label: {
175 Label("Remove Access", systemImage: "trash")
176 }
177 }
178 }
179 .themedRow()
180 }
181
182 HStack {
183 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
184 .autocorrectionDisabled()
185 .textInputAutocapitalization(.never)
186
187 Picker("", selection: Bindable(viewModel).newACLMode) {
188 Text("RO").tag("RO")
189 Text("RW").tag("RW")
190 }
191 .pickerStyle(.segmented)
192 .frame(width: 100)
193
194 Button {
195 Task { await viewModel.addACL() }
196 } label: {
197 if viewModel.isAddingACL {
198 ProgressView()
199 } else {
200 Text("Add")
201 }
202 }
203 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
204 }
205 .themedRow()
206 Text("Add a SourceHut user and choose read-only or read/write access.")
207 .font(.caption)
208 .foregroundStyle(.secondary)
209 .themedRow()
210 }
211 }
212
213 @ViewBuilder
214 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
215 Section("Sensitive Settings") {
216 Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
217 .themedRow()
218 Text("Changes stay pending until you save this section.")
219 .font(.caption)
220 .foregroundStyle(.secondary)
221 .themedRow()
222
223 Button {
224 Task {
225 _ = await viewModel.saveInfo()
226 }
227 } label: {
228 if viewModel.isSavingInfo {
229 ProgressView()
230 .frame(maxWidth: .infinity)
231 } else {
232 Text("Save Changes")
233 .frame(maxWidth: .infinity)
234 }
235 }
236 .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
237 .themedRow()
238 }
239 }
240
241 @ViewBuilder
242 private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
243 Section("Histedit") {
244 TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
245 .autocorrectionDisabled()
246 .textInputAutocapitalization(.never)
247 .disabled(true)
248 .themedRow()
249
250 Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
251 .font(.caption)
252 .foregroundStyle(.secondary)
253 .themedRow()
254
255 Button("Remove Revision", role: .destructive) {
256 // Not implemented: the hg.sr.ht API does not expose a histedit endpoint.
257 // This button is disabled until the API supports revision removal.
258 }
259 .disabled(true)
260 .themedRow()
261 }
262 }
263
264 @ViewBuilder
265 private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
266 Section {
267 Button(role: .destructive) {
268 showDeleteConfirmation = true
269 } label: {
270 if viewModel.isDeleting {
271 ProgressView()
272 .frame(maxWidth: .infinity)
273 } else {
274 Text("Delete Repository")
275 .frame(maxWidth: .infinity)
276 }
277 }
278 .disabled(viewModel.isDeleting)
279 .themedRow()
280 } header: {
281 Text("Danger Zone")
282 }
283 }
284}