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}