krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.0: 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 @State private var saveResultAlert: SaveResultAlert?
13
14 var body: some View {
15 NavigationStack {
16 Group {
17 if let viewModel {
18 settingsForm(viewModel)
19 } else {
20 SRHTLoadingStateView(message: "Loading settings…")
21 }
22 }
23 .navigationTitle("Settings")
24 .navigationBarTitleDisplayMode(.inline)
25 .toolbar {
26 ToolbarItem(placement: .cancellationAction) {
27 Button("Done") { dismiss() }
28 }
29 }
30 }
31 .task {
32 if viewModel == nil {
33 let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
34 viewModel = vm
35 async let info: () = vm.loadRepositoryInfo()
36 async let acls: () = vm.loadACLs()
37 _ = await (info, acls)
38 }
39 }
40 }
41
42 @ViewBuilder
43 private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
44 @Bindable var vm = viewModel
45
46 Form {
47 infoSection(viewModel)
48 accessSection(viewModel)
49 featuresSection(viewModel)
50 histeditSection(viewModel)
51 deleteSection(viewModel)
52 }
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 .alert(item: $saveResultAlert) { alert in
97 Alert(
98 title: Text(alert.title),
99 message: Text(alert.message),
100 dismissButton: .default(Text("OK"))
101 )
102 }
103 }
104
105 @ViewBuilder
106 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
107 Section("Info") {
108 LabeledContent("Name") {
109 Text(repository.name)
110 .font(.body.monospaced())
111 }
112
113 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
114 .lineLimit(3...6)
115
116 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
117 Text("Public").tag(Visibility.public)
118 Text("Unlisted").tag(Visibility.unlisted)
119 Text("Private").tag(Visibility.private)
120 }
121
122 Button {
123 Task {
124 let didSave = await viewModel.saveInfo()
125 saveResultAlert = SaveResultAlert(
126 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
127 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
128 )
129 }
130 } label: {
131 if viewModel.isSavingInfo {
132 ProgressView()
133 .frame(maxWidth: .infinity)
134 } else {
135 Text("Save Changes")
136 .frame(maxWidth: .infinity)
137 }
138 }
139 .disabled(viewModel.isSavingInfo)
140 }
141 }
142
143 @ViewBuilder
144 private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
145 Section("Access") {
146 if viewModel.isLoadingACLs {
147 HStack {
148 Spacer()
149 ProgressView()
150 Spacer()
151 }
152 } else if viewModel.acls.isEmpty {
153 Text("No access entries yet.")
154 .foregroundStyle(.secondary)
155 } else {
156 ForEach(viewModel.acls) { entry in
157 HStack {
158 Text(entry.entity.canonicalName)
159 Spacer()
160 Text(entry.mode)
161 .font(.caption.monospaced())
162 .foregroundStyle(.secondary)
163 }
164 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
165 Button(role: .destructive) {
166 pendingACLDeletion = entry
167 } label: {
168 Label("Remove Access", systemImage: "trash")
169 }
170 }
171 }
172 }
173
174 HStack {
175 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
176 .autocorrectionDisabled()
177 .textInputAutocapitalization(.never)
178
179 Picker("", selection: Bindable(viewModel).newACLMode) {
180 Text("RO").tag("RO")
181 Text("RW").tag("RW")
182 }
183 .pickerStyle(.segmented)
184 .frame(width: 100)
185
186 Button {
187 Task { await viewModel.addACL() }
188 } label: {
189 if viewModel.isAddingACL {
190 ProgressView()
191 } else {
192 Text("Add")
193 }
194 }
195 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
196 }
197 Text("Add a SourceHut user and choose read-only or read/write access.")
198 .font(.caption)
199 .foregroundStyle(.secondary)
200 }
201 }
202
203 @ViewBuilder
204 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
205 Section("Features") {
206 Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
207
208 Button {
209 Task {
210 let didSave = await viewModel.saveInfo()
211 saveResultAlert = SaveResultAlert(
212 title: didSave ? "Settings Updated" : "Couldn't Update Settings",
213 message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
214 )
215 }
216 } label: {
217 if viewModel.isSavingInfo {
218 ProgressView()
219 .frame(maxWidth: .infinity)
220 } else {
221 Text("Save Changes")
222 .frame(maxWidth: .infinity)
223 }
224 }
225 .disabled(viewModel.isSavingInfo)
226 }
227 }
228
229 @ViewBuilder
230 private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
231 Section("Histedit") {
232 TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
233 .autocorrectionDisabled()
234 .textInputAutocapitalization(.never)
235 .disabled(true)
236
237 Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
238 .font(.caption)
239 .foregroundStyle(.secondary)
240
241 Button("Remove Revision", role: .destructive) {
242 // Not implemented: the hg.sr.ht API does not expose a histedit endpoint.
243 // This button is disabled until the API supports revision removal.
244 }
245 .disabled(true)
246 }
247 }
248
249 @ViewBuilder
250 private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
251 Section {
252 Button(role: .destructive) {
253 showDeleteConfirmation = true
254 } label: {
255 if viewModel.isDeleting {
256 ProgressView()
257 .frame(maxWidth: .infinity)
258 } else {
259 Text("Delete Repository")
260 .frame(maxWidth: .infinity)
261 }
262 }
263 .disabled(viewModel.isDeleting)
264 }
265 }
266
267 private struct SaveResultAlert: Identifiable {
268 let title: String
269 let message: String
270
271 var id: String { "\(title)-\(message)" }
272 }
273}