krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: Hutch/Views/Repositories/RepositorySettingsView.swift · raw
1import SwiftUI
2
3struct RepositorySettingsView: View {
4 let repository: RepositorySummary
5 let branches: [Reference]
6 let client: SRHTClient
7 let onRenamed: (String) -> Void
8 let onDeleted: () -> Void
9
10 @Environment(\.dismiss) private var dismiss
11 @State private var viewModel: RepositorySettingsViewModel?
12 @State private var showDeleteConfirmation = false
13 @State private var pendingACLDeletion: ACLEntry?
14
15 var body: some View {
16 NavigationStack {
17 Group {
18 if let viewModel {
19 settingsForm(viewModel)
20 } else {
21 SRHTLoadingStateView(message: "Loading settings…")
22 }
23 }
24 .navigationTitle("Settings")
25 .navigationBarTitleDisplayMode(.inline)
26 .toolbar {
27 ToolbarItem(placement: .cancellationAction) {
28 Button("Done") { dismiss() }
29 }
30 }
31 }
32 .task {
33 if viewModel == nil {
34 let vm = RepositorySettingsViewModel(
35 repository: repository,
36 branches: branches,
37 client: client
38 )
39 viewModel = vm
40 await vm.loadACLs()
41 }
42 }
43 }
44
45 @ViewBuilder
46 private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
47 @Bindable var vm = viewModel
48
49 Form {
50 infoSection(viewModel)
51 renameSection(viewModel)
52 accessSection(viewModel)
53 deleteSection(viewModel)
54 }
55 .srhtErrorBanner(error: $vm.error)
56 .alert(
57 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
58 isPresented: $showDeleteConfirmation
59 ) {
60 Button("Cancel", role: .cancel) {}
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 Button("Remove Access", role: .destructive) {
83 guard let entry = pendingACLDeletion else { return }
84 Task {
85 await viewModel.deleteACL(entry)
86 pendingACLDeletion = nil
87 }
88 }
89 } message: {
90 if let entry = pendingACLDeletion {
91 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
92 }
93 }
94 }
95
96 // MARK: - Info Section
97
98 @ViewBuilder
99 private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
100 Section("Info") {
101 LabeledContent("Name") {
102 Text(repository.name)
103 .font(.body.monospaced())
104 }
105
106 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
107 .lineLimit(3...6)
108
109 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
110 Text("Public").tag(Visibility.public)
111 Text("Unlisted").tag(Visibility.unlisted)
112 Text("Private").tag(Visibility.private)
113 }
114
115 if !viewModel.branches.isEmpty {
116 Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
117 ForEach(viewModel.branches, id: \.name) { branch in
118 let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
119 Text(name).tag(name)
120 }
121 }
122 }
123
124 Button {
125 Task { await viewModel.saveInfo() }
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 // MARK: - Rename Section
140
141 @ViewBuilder
142 private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
143 Section {
144 TextField("New repository name", text: Bindable(viewModel).editedName)
145 .autocorrectionDisabled()
146 .textInputAutocapitalization(.never)
147
148 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
149 .font(.caption)
150 .foregroundStyle(.secondary)
151
152 Button {
153 Task {
154 await viewModel.rename()
155 if let newName = viewModel.updatedName {
156 onRenamed(newName)
157 dismiss()
158 }
159 }
160 } label: {
161 if viewModel.isRenaming {
162 ProgressView()
163 .frame(maxWidth: .infinity)
164 } else {
165 Text("Rename Repository")
166 .frame(maxWidth: .infinity)
167 }
168 }
169 .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
170 } header: {
171 Text("Rename")
172 }
173 }
174
175 // MARK: - Access Section
176
177 @ViewBuilder
178 private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
179 Section {
180 if viewModel.isLoadingACLs {
181 HStack {
182 Spacer()
183 ProgressView()
184 Spacer()
185 }
186 } else if viewModel.acls.isEmpty {
187 Text("No access entries yet.")
188 .foregroundStyle(.secondary)
189 } else {
190 ForEach(viewModel.acls) { entry in
191 HStack {
192 Text(entry.entity.canonicalName)
193 Spacer()
194 Text(entry.mode)
195 .font(.caption.monospaced())
196 .foregroundStyle(.secondary)
197 }
198 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
199 Button(role: .destructive) {
200 pendingACLDeletion = entry
201 } label: {
202 Label("Remove Access", systemImage: "trash")
203 }
204 }
205 }
206 }
207
208 // Add ACL form
209 HStack {
210 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
211 .autocorrectionDisabled()
212 .textInputAutocapitalization(.never)
213
214 Picker("", selection: Bindable(viewModel).newACLMode) {
215 Text("RO").tag("RO")
216 Text("RW").tag("RW")
217 }
218 .pickerStyle(.segmented)
219 .frame(width: 100)
220
221 Button {
222 Task { await viewModel.addACL() }
223 } label: {
224 if viewModel.isAddingACL {
225 ProgressView()
226 } else {
227 Text("Add")
228 }
229 }
230 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
231 }
232 Text("Add a SourceHut user and choose read-only or read/write access.")
233 .font(.caption)
234 .foregroundStyle(.secondary)
235 } header: {
236 Text("Access")
237 }
238 }
239
240 // MARK: - Delete Section
241
242 @ViewBuilder
243 private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> 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}