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