krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.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}