krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.11.0: Hutch/Views/Projects/ManageProjectResourcesView.swift · raw
1import SwiftUI
2
3@Observable
4@MainActor
5final class ManageProjectResourcesViewModel {
6 private(set) var sources: [Project.SourceRepo]
7 private(set) var trackers: [Project.Tracker]
8 private(set) var mailingLists: [Project.MailingList]
9 private(set) var busyID: String?
10 var error: String?
11
12 let projectID: String
13 let projectName: String
14 private let service: ProjectService
15 private let onChange: () async -> Void
16
17 init(project: Project, service: ProjectService, onChange: @escaping () async -> Void) {
18 projectID = project.id
19 projectName = project.displayName
20 sources = project.sources
21 trackers = project.trackers
22 mailingLists = project.mailingLists
23 self.service = service
24 self.onChange = onChange
25 }
26
27 /// rids already linked, so the candidate pickers can hide them.
28 var linkedRIDs: Set<String> {
29 Set(sources.map(\.id) + trackers.map(\.id) + mailingLists.map(\.id))
30 }
31
32 func unlink(source: Project.SourceRepo) async {
33 await mutate(id: source.id) {
34 try await self.service.unlinkSource(projectID: self.projectID, sourceRepoID: source.id)
35 self.sources.removeAll { $0.id == source.id }
36 }
37 }
38
39 func unlink(tracker: Project.Tracker) async {
40 await mutate(id: tracker.id) {
41 try await self.service.unlinkTracker(projectID: self.projectID, trackerID: tracker.id)
42 self.trackers.removeAll { $0.id == tracker.id }
43 }
44 }
45
46 func unlink(mailingList: Project.MailingList) async {
47 await mutate(id: mailingList.id) {
48 try await self.service.unlinkMailingList(projectID: self.projectID, listID: mailingList.id)
49 self.mailingLists.removeAll { $0.id == mailingList.id }
50 }
51 }
52
53 func link(_ resource: LinkableResource) async {
54 await mutate(id: resource.rid) {
55 switch resource.kind {
56 case .source:
57 try await self.service.linkSource(projectID: self.projectID, sourceRepoID: resource.rid)
58 case .tracker:
59 try await self.service.linkTracker(projectID: self.projectID, trackerID: resource.rid)
60 case .mailingList:
61 try await self.service.linkMailingList(projectID: self.projectID, listID: resource.rid)
62 }
63 try await self.reload()
64 }
65 }
66
67 func candidates(for kind: LinkableResource.Kind) async throws -> [LinkableResource] {
68 let all: [LinkableResource]
69 switch kind {
70 case .source: all = try await service.fetchLinkableSources()
71 case .tracker: all = try await service.fetchLinkableTrackers()
72 case .mailingList: all = try await service.fetchLinkableMailingLists()
73 }
74 let linked = linkedRIDs
75 return all.filter { !linked.contains($0.rid) }
76 }
77
78 private func reload() async throws {
79 let project = try await service.fetchProjectDetail(rid: projectID)
80 sources = project.sources
81 trackers = project.trackers
82 mailingLists = project.mailingLists
83 }
84
85 private func mutate(id: String, _ work: @escaping () async throws -> Void) async {
86 guard busyID == nil else { return }
87 busyID = id
88 error = nil
89 defer { busyID = nil }
90 do {
91 try await work()
92 await onChange()
93 } catch {
94 self.error = error.userFacingMessage
95 }
96 }
97}
98
99struct ManageProjectResourcesView: View {
100 let project: Project
101 let onChange: () async -> Void
102
103 @Environment(AppState.self) private var appState
104 @Environment(\.dismiss) private var dismiss
105 @State private var viewModel: ManageProjectResourcesViewModel?
106 @State private var addKind: LinkableResource.Kind?
107
108 var body: some View {
109 NavigationStack {
110 Group {
111 if let viewModel {
112 content(viewModel)
113 } else {
114 SRHTLoadingStateView(message: "Loading…")
115 }
116 }
117 .navigationTitle("Linked Resources")
118 .navigationBarTitleDisplayMode(.inline)
119 .toolbar {
120 ToolbarItem(placement: .confirmationAction) {
121 Button("Done") { dismiss() }
122 }
123 ToolbarItem(placement: .topBarLeading) {
124 Menu {
125 Button("Add Repository") { addKind = .source }
126 Button("Add Tracker") { addKind = .tracker }
127 Button("Add Mailing List") { addKind = .mailingList }
128 } label: {
129 Image(systemName: "plus")
130 }
131 .accessibilityLabel("Add linked resource")
132 }
133 }
134 .sheet(item: Binding(get: { addKind.map { AddKind(kind: $0) } }, set: { addKind = $0?.kind })) { wrapper in
135 if let viewModel {
136 LinkableResourcePicker(kind: wrapper.kind, viewModel: viewModel)
137 }
138 }
139 }
140 .task {
141 if viewModel == nil {
142 viewModel = ManageProjectResourcesViewModel(
143 project: project,
144 service: ProjectService(client: appState.client),
145 onChange: onChange
146 )
147 }
148 }
149 }
150
151 @ViewBuilder
152 private func content(_ viewModel: ManageProjectResourcesViewModel) -> some View {
153 List {
154 resourceSection(
155 title: "Repositories",
156 items: viewModel.sources,
157 busyID: viewModel.busyID,
158 label: { "\($0.ownerUsername)/\($0.displayName)" },
159 onDelete: { await viewModel.unlink(source: $0) }
160 )
161 resourceSection(
162 title: "Trackers",
163 items: viewModel.trackers,
164 busyID: viewModel.busyID,
165 label: { "\($0.ownerUsername)/\($0.displayName)" },
166 onDelete: { await viewModel.unlink(tracker: $0) }
167 )
168 resourceSection(
169 title: "Mailing Lists",
170 items: viewModel.mailingLists,
171 busyID: viewModel.busyID,
172 label: { "\($0.ownerUsername)/\($0.displayName)" },
173 onDelete: { await viewModel.unlink(mailingList: $0) }
174 )
175
176 if viewModel.sources.isEmpty, viewModel.trackers.isEmpty, viewModel.mailingLists.isEmpty {
177 Section {
178 Text("No linked resources. Use + to add repositories, trackers, or mailing lists.")
179 .foregroundStyle(.secondary)
180 .themedRow()
181 }
182 }
183 }
184 .themedList()
185 .srhtErrorBanner(
186 error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 })
187 )
188 }
189
190 @ViewBuilder
191 private func resourceSection<Item: Identifiable>(
192 title: String,
193 items: [Item],
194 busyID: String?,
195 label: @escaping (Item) -> String,
196 onDelete: @escaping (Item) async -> Void
197 ) -> some View where Item.ID == String {
198 if !items.isEmpty {
199 Section(title) {
200 ForEach(items) { item in
201 HStack {
202 Text(label(item))
203 .font(.body.monospaced())
204 .lineLimit(1)
205 Spacer()
206 if busyID == item.id {
207 ProgressView().controlSize(.small)
208 }
209 }
210 .themedRow()
211 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
212 Button(role: .destructive) {
213 Task { await onDelete(item) }
214 } label: {
215 Label("Unlink", systemImage: "link.badge.minus")
216 }
217 }
218 }
219 }
220 }
221 }
222}
223
224private struct AddKind: Identifiable {
225 let kind: LinkableResource.Kind
226 var id: String {
227 switch kind {
228 case .source: "source"
229 case .tracker: "tracker"
230 case .mailingList: "mailingList"
231 }
232 }
233}
234
235private struct LinkableResourcePicker: View {
236 let kind: LinkableResource.Kind
237 let viewModel: ManageProjectResourcesViewModel
238
239 @Environment(\.dismiss) private var dismiss
240 @State private var candidates: [LinkableResource] = []
241 @State private var isLoading = true
242 @State private var error: String?
243
244 private var title: String {
245 switch kind {
246 case .source: "Add Repository"
247 case .tracker: "Add Tracker"
248 case .mailingList: "Add Mailing List"
249 }
250 }
251
252 var body: some View {
253 NavigationStack {
254 Group {
255 if isLoading {
256 SRHTLoadingStateView(message: "Loading…")
257 } else if let error {
258 SRHTErrorStateView(title: "Couldn't Load", message: error, retryAction: { await load() })
259 } else if candidates.isEmpty {
260 ContentUnavailableView(
261 "Nothing to Add",
262 systemImage: "checkmark.circle",
263 description: Text("There are no more resources of this type to link.")
264 )
265 } else {
266 List(candidates) { candidate in
267 Button {
268 Task {
269 await viewModel.link(candidate)
270 dismiss()
271 }
272 } label: {
273 Text(candidate.displayName)
274 .font(.body.monospaced())
275 .lineLimit(1)
276 }
277 .themedRow()
278 }
279 .themedList()
280 }
281 }
282 .navigationTitle(title)
283 .navigationBarTitleDisplayMode(.inline)
284 .toolbar {
285 ToolbarItem(placement: .cancellationAction) {
286 Button("Cancel") { dismiss() }
287 }
288 }
289 }
290 .task { await load() }
291 }
292
293 private func load() async {
294 isLoading = true
295 error = nil
296 do {
297 candidates = try await viewModel.candidates(for: kind)
298 } catch {
299 self.error = error.userFacingMessage
300 }
301 isLoading = false
302 }
303}