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