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