Commit 0cc97ef4f1

0cc97ef4f1b16122ef9510640b849f0ca68dd422

parent: f17b2ae63e

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 09:12 UTC

Projects: discovery, create, edit, manage linked resources (#12, #13, #14, #15)

hub.sr.ht's GraphQL now exposes the project write API (create/update/
link/unlink) and a public `projects` discovery query, so these
previously-blocked issues can be built.

ProjectService gains: fetchPublicProjects (#12); createProject (#13);
updateProject with ProjectInput (#14); link/unlink for sources, trackers,
and mailing lists plus linkable-resource candidate fetches (#15); and hub
cache invalidation after every mutation.

UI: a Discover browser and a create form reached from the projects list;
an Edit form and a Manage Resources sheet on project detail, gated to
projects the user owns. Mutations degrade to a visible error if a field
isn't live yet.

Built against the master schema; live deployment on sr.ht/query is not yet
confirmed (introspection there requires a token).

Layout: unified · split

Hutch/Networking/ProjectService.swift +469
@@ -198,6 +198,170 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
198198 }
199199}
200200
201/// A public project surfaced by discovery, carrying its owner for display.
202struct DiscoveredProject: Identifiable, Hashable, Sendable {
203 let project: Project
204 let ownerCanonicalName: String
205
206 var id: String { project.id }
207}
208
209struct DiscoveredProjectsPage: Sendable {
210 let projects: [DiscoveredProject]
211 let cursor: String?
212}
213
214private struct PublicProjectsResponse: Decodable, Sendable {
215 let projects: PublicProjectPage
216}
217
218private struct PublicProjectPage: Decodable, Sendable {
219 let results: [PublicProjectPayload]
220 let cursor: String?
221
222 init(from decoder: any Decoder) throws {
223 enum CodingKeys: String, CodingKey { case results, cursor }
224 let container = try decoder.container(keyedBy: CodingKeys.self)
225 results = try container.decodeIfPresent([PublicProjectPayload].self, forKey: .results) ?? []
226 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
227 }
228}
229
230private struct PublicProjectPayload: Decodable, Sendable {
231 let rid: String
232 let name: String
233 let description: String?
234 let website: String?
235 let visibility: Visibility
236 let tags: [String]
237 let updated: Date
238 let owner: Entity
239
240 init(from decoder: any Decoder) throws {
241 enum CodingKeys: String, CodingKey {
242 case rid, name, description, website, visibility, tags, updated, owner
243 }
244 let container = try decoder.container(keyedBy: CodingKeys.self)
245 rid = try container.decode(String.self, forKey: .rid)
246 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
247 description = try container.decodeIfPresent(String.self, forKey: .description)
248 website = try container.decodeIfPresent(String.self, forKey: .website)
249 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
250 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
251 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
252 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
253 }
254}
255
256/// A resource the current user can link to a project (#15 add flow).
257struct LinkableResource: Identifiable, Hashable, Sendable {
258 enum Kind: Sendable { case source, tracker, mailingList }
259
260 let rid: String
261 let name: String
262 let ownerCanonicalName: String
263 let kind: Kind
264
265 var id: String { rid }
266 var displayName: String { "\(ownerCanonicalName)/\(name)" }
267}
268
269private struct CandidatePayload: Decodable, Sendable {
270 let rid: String
271 let name: String
272 let owner: Entity?
273}
274
275private struct CandidatePage: Decodable, Sendable {
276 let results: [CandidatePayload]
277 let cursor: String?
278
279 init(from decoder: any Decoder) throws {
280 enum CodingKeys: String, CodingKey { case results, cursor }
281 let container = try decoder.container(keyedBy: CodingKeys.self)
282 results = try container.decodeIfPresent([CandidatePayload].self, forKey: .results) ?? []
283 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
284 }
285}
286
287private struct RepoCandidatesResponse: Decodable, Sendable {
288 let repositories: CandidatePage
289}
290
291private struct TrackerCandidatesResponse: Decodable, Sendable {
292 let trackers: CandidatePage
293}
294
295private struct ListSubscriptionPayload: Decodable, Sendable {
296 let list: CandidatePayload?
297}
298
299private struct ListSubscriptionPage: Decodable, Sendable {
300 let results: [ListSubscriptionPayload]
301 let cursor: String?
302
303 init(from decoder: any Decoder) throws {
304 enum CodingKeys: String, CodingKey { case results, cursor }
305 let container = try decoder.container(keyedBy: CodingKeys.self)
306 results = try container.decodeIfPresent([ListSubscriptionPayload].self, forKey: .results) ?? []
307 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
308 }
309}
310
311private struct ListCandidatesResponse: Decodable, Sendable {
312 let subscriptions: ListSubscriptionPage
313}
314
315/// Link/unlink mutations only need success; the returned resource is ignored.
316private struct LinkMutationResponse: Decodable, Sendable {}
317
318private struct CreateProjectResponse: Decodable, Sendable {
319 let createProject: MutatedProjectPayload?
320}
321
322private struct UpdateProjectResponse: Decodable, Sendable {
323 let updateProject: MutatedProjectPayload?
324}
325
326private struct MutatedProjectPayload: Decodable, Sendable {
327 let rid: String
328 let name: String
329 let description: String?
330 let website: String?
331 let visibility: Visibility
332 let tags: [String]
333 let updated: Date
334
335 init(from decoder: any Decoder) throws {
336 enum CodingKeys: String, CodingKey {
337 case rid, name, description, website, visibility, tags, updated
338 }
339 let container = try decoder.container(keyedBy: CodingKeys.self)
340 rid = try container.decode(String.self, forKey: .rid)
341 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
342 description = try container.decodeIfPresent(String.self, forKey: .description)
343 website = try container.decodeIfPresent(String.self, forKey: .website)
344 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
345 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
346 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
347 }
348
349 var project: Project {
350 Project(
351 metadata: .init(
352 id: rid,
353 name: name,
354 description: description,
355 website: website,
356 visibility: visibility,
357 tags: tags,
358 updated: updated
359 ),
360 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
361 )
362 }
363}
364
201365struct ProjectService: Sendable {
202366 private let client: SRHTClient
203367
@@ -265,6 +429,91 @@ struct ProjectService: Sendable {
265429 }
266430 """
267431
432 private static let publicProjectsQuery = """
433 query publicProjects($cursor: Cursor) {
434 projects(cursor: $cursor) {
435 results {
436 rid
437 name
438 description
439 website
440 visibility
441 tags
442 updated
443 owner { canonicalName }
444 }
445 cursor
446 }
447 }
448 """
449
450 private static let createProjectMutation = """
451 mutation createProject($name: String!, $visibility: Visibility!, $description: String, $tags: [String!]) {
452 createProject(name: $name, visibility: $visibility, description: $description, tags: $tags) {
453 rid
454 name
455 description
456 website
457 visibility
458 tags
459 updated
460 }
461 }
462 """
463
464 private static let updateProjectMutation = """
465 mutation updateProject($rid: ID!, $input: ProjectInput!) {
466 updateProject(rid: $rid, input: $input) {
467 rid
468 name
469 description
470 website
471 visibility
472 tags
473 updated
474 }
475 }
476 """
477
478 private static func linkMutation(field: String, resourceParam: String) -> String {
479 """
480 mutation link($projectID: ID!, $resourceID: ID!) {
481 \(field)(projectID: $projectID, \(resourceParam): $resourceID) { rid }
482 }
483 """
484 }
485
486 private static let repositoriesCandidatesQuery = """
487 query repositories($cursor: Cursor) {
488 repositories(cursor: $cursor) {
489 results { rid name owner { canonicalName } }
490 cursor
491 }
492 }
493 """
494
495 private static let trackersCandidatesQuery = """
496 query trackers($cursor: Cursor) {
497 trackers(cursor: $cursor) {
498 results { rid name owner { canonicalName } }
499 cursor
500 }
501 }
502 """
503
504 private static let listCandidatesQuery = """
505 query subscriptions($cursor: Cursor) {
506 subscriptions(cursor: $cursor) {
507 results {
508 ... on MailingListSubscription {
509 list { rid name owner { canonicalName } }
510 }
511 }
512 cursor
513 }
514 }
515 """
516
268517 init(client: SRHTClient) {
269518 self.client = client
270519 }
@@ -277,6 +526,226 @@ struct ProjectService: Sendable {
277526 try await fetchProjectDetailPayload(rid: rid)
278527 }
279528
529 // MARK: - Discovery (#12)
530
531 /// Lists public projects across all users. Not cached — discovery is
532 /// browsed live and paginated by the caller.
533 func fetchPublicProjects(cursor: String? = nil) async throws -> DiscoveredProjectsPage {
534 var variables: [String: any Sendable] = [:]
535 if let cursor {
536 variables["cursor"] = cursor
537 }
538
539 let response = try await client.execute(
540 service: .hub,
541 query: Self.publicProjectsQuery,
542 variables: variables.isEmpty ? nil : variables,
543 responseType: PublicProjectsResponse.self
544 )
545
546 let projects = response.projects.results.map { payload in
547 DiscoveredProject(
548 project: Project(
549 metadata: .init(
550 id: payload.rid,
551 name: payload.name,
552 description: payload.description,
553 website: payload.website,
554 visibility: payload.visibility,
555 tags: payload.tags,
556 updated: payload.updated
557 ),
558 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
559 ),
560 ownerCanonicalName: payload.owner.canonicalName
561 )
562 }
563 return DiscoveredProjectsPage(projects: projects, cursor: response.projects.cursor)
564 }
565
566 // MARK: - Mutations (#13, #14, #15)
567
568 func createProject(
569 name: String,
570 visibility: Visibility,
571 description: String?,
572 tags: [String]
573 ) async throws -> Project {
574 var variables: [String: any Sendable] = ["name": name, "visibility": visibility.rawValue]
575 if let description, !description.isEmpty {
576 variables["description"] = description
577 }
578 if !tags.isEmpty {
579 variables["tags"] = tags
580 }
581
582 let response = try await client.execute(
583 service: .hub,
584 query: Self.createProjectMutation,
585 variables: variables,
586 responseType: CreateProjectResponse.self
587 )
588 await invalidateProjectCaches()
589
590 guard let payload = response.createProject else {
591 throw SRHTError.decodingError(
592 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing createProject payload"))
593 )
594 }
595 return payload.project
596 }
597
598 /// Updates a project. Only non-nil fields are sent; `description`/`website`
599 /// pass an empty string to clear the field.
600 func updateProject(
601 rid: String,
602 name: String? = nil,
603 description: String? = nil,
604 website: String? = nil,
605 visibility: Visibility? = nil,
606 tags: [String]? = nil
607 ) async throws -> Project {
608 var input: [String: any Sendable] = [:]
609 if let name {
610 input["name"] = name
611 }
612 if let description {
613 input["description"] = description
614 }
615 if let website {
616 input["website"] = website
617 }
618 if let visibility {
619 input["visibility"] = visibility.rawValue
620 }
621 if let tags {
622 input["tags"] = tags
623 }
624
625 let response = try await client.execute(
626 service: .hub,
627 query: Self.updateProjectMutation,
628 variables: ["rid": rid, "input": input],
629 responseType: UpdateProjectResponse.self
630 )
631 await invalidateProjectCaches()
632
633 guard let payload = response.updateProject else {
634 throw SRHTError.decodingError(
635 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing updateProject payload"))
636 )
637 }
638 return payload.project
639 }
640
641 func linkSource(projectID: String, sourceRepoID: String) async throws {
642 try await runLink(field: "linkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
643 }
644
645 func unlinkSource(projectID: String, sourceRepoID: String) async throws {
646 try await runLink(field: "unlinkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
647 }
648
649 func linkTracker(projectID: String, trackerID: String) async throws {
650 try await runLink(field: "linkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
651 }
652
653 func unlinkTracker(projectID: String, trackerID: String) async throws {
654 try await runLink(field: "unlinkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
655 }
656
657 func linkMailingList(projectID: String, listID: String) async throws {
658 try await runLink(field: "linkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
659 }
660
661 func unlinkMailingList(projectID: String, listID: String) async throws {
662 try await runLink(field: "unlinkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
663 }
664
665 private func runLink(field: String, resourceParam: String, projectID: String, resourceID: String) async throws {
666 _ = try await client.execute(
667 service: .hub,
668 query: Self.linkMutation(field: field, resourceParam: resourceParam),
669 variables: ["projectID": projectID, "resourceID": resourceID],
670 responseType: LinkMutationResponse.self
671 )
672 await invalidateProjectCaches()
673 }
674
675 private func invalidateProjectCaches() async {
676 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.hub.rawValue))
677 }
678
679 // MARK: - Linkable resource candidates (#15 add flow)
680
681 /// Repositories the user can link (git and hg), sorted by display name.
682 func fetchLinkableSources() async throws -> [LinkableResource] {
683 async let git = fetchRepoCandidates(service: .git)
684 async let hg = fetchRepoCandidates(service: .hg)
685 return dedupeSorted(try await git + (try await hg))
686 }
687
688 func fetchLinkableTrackers() async throws -> [LinkableResource] {
689 var results: [LinkableResource] = []
690 var cursor: String?
691 repeat {
692 let response = try await client.execute(
693 service: .todo,
694 query: Self.trackersCandidatesQuery,
695 variables: cursor.map { ["cursor": $0] },
696 responseType: TrackerCandidatesResponse.self
697 )
698 results.append(contentsOf: response.trackers.results.map {
699 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .tracker)
700 })
701 cursor = response.trackers.cursor
702 } while cursor != nil
703 return dedupeSorted(results)
704 }
705
706 func fetchLinkableMailingLists() async throws -> [LinkableResource] {
707 var results: [LinkableResource] = []
708 var cursor: String?
709 repeat {
710 let response = try await client.execute(
711 service: .lists,
712 query: Self.listCandidatesQuery,
713 variables: cursor.map { ["cursor": $0] },
714 responseType: ListCandidatesResponse.self
715 )
716 results.append(contentsOf: response.subscriptions.results.compactMap(\.list).map {
717 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .mailingList)
718 })
719 cursor = response.subscriptions.cursor
720 } while cursor != nil
721 return dedupeSorted(results)
722 }
723
724 private func fetchRepoCandidates(service: SRHTService) async throws -> [LinkableResource] {
725 var results: [LinkableResource] = []
726 var cursor: String?
727 repeat {
728 let response = try await client.execute(
729 service: service,
730 query: Self.repositoriesCandidatesQuery,
731 variables: cursor.map { ["cursor": $0] },
732 responseType: RepoCandidatesResponse.self
733 )
734 results.append(contentsOf: response.repositories.results.map {
735 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .source)
736 })
737 cursor = response.repositories.cursor
738 } while cursor != nil
739 return results
740 }
741
742 private func dedupeSorted(_ items: [LinkableResource]) -> [LinkableResource] {
743 var seen = Set<String>()
744 return items
745 .filter { seen.insert($0.rid).inserted }
746 .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
747 }
748
280749 private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] {
281750 var results: [ProjectSummaryPayload] = []
282751 var cursor: String?
Hutch/Views/Projects/DiscoverProjectsView.swift added +166
@@ -0,0 +1,166 @@
1import SwiftUI
2
3@Observable
4@MainActor
5final class DiscoverProjectsViewModel {
6 private(set) var projects: [DiscoveredProject] = []
7 private(set) var isLoading = false
8 private(set) var isLoadingMore = false
9 var error: String?
10
11 private var cursor: String?
12 private var canLoadMore = true
13 private let service: ProjectService
14
15 init(service: ProjectService) {
16 self.service = service
17 }
18
19 func loadInitial() async {
20 guard projects.isEmpty, !isLoading else { return }
21 isLoading = true
22 error = nil
23 defer { isLoading = false }
24
25 do {
26 let page = try await service.fetchPublicProjects(cursor: nil)
27 projects = page.projects
28 cursor = page.cursor
29 canLoadMore = page.cursor != nil
30 } catch {
31 self.error = error.userFacingMessage
32 }
33 }
34
35 func reload() async {
36 cursor = nil
37 canLoadMore = true
38 projects = []
39 await loadInitial()
40 }
41
42 func loadMoreIfNeeded(current item: DiscoveredProject) async {
43 guard canLoadMore, !isLoadingMore, !isLoading else { return }
44 guard let index = projects.firstIndex(of: item), index >= projects.count - 3 else { return }
45
46 isLoadingMore = true
47 defer { isLoadingMore = false }
48
49 do {
50 let page = try await service.fetchPublicProjects(cursor: cursor)
51 let existing = Set(projects.map(\.id))
52 projects.append(contentsOf: page.projects.filter { !existing.contains($0.id) })
53 cursor = page.cursor
54 canLoadMore = page.cursor != nil
55 } catch {
56 self.error = error.userFacingMessage
57 }
58 }
59}
60
61struct DiscoverProjectsView: View {
62 @Environment(AppState.self) private var appState
63 @State private var viewModel: DiscoverProjectsViewModel?
64
65 var body: some View {
66 Group {
67 if let viewModel {
68 content(viewModel)
69 } else {
70 SRHTLoadingStateView(message: "Loading projects…")
71 }
72 }
73 .navigationTitle("Discover")
74 .navigationBarTitleDisplayMode(.inline)
75 .task {
76 if viewModel == nil {
77 let vm = DiscoverProjectsViewModel(service: ProjectService(client: appState.client))
78 viewModel = vm
79 await vm.loadInitial()
80 }
81 }
82 }
83
84 @ViewBuilder
85 private func content(_ viewModel: DiscoverProjectsViewModel) -> some View {
86 List {
87 ForEach(viewModel.projects) { discovered in
88 NavigationLink {
89 ProjectDetailView(
90 project: discovered.project,
91 canManage: discovered.ownerCanonicalName == appState.currentUser?.canonicalName
92 )
93 } label: {
94 DiscoveredProjectRow(discovered: discovered)
95 }
96 .buttonStyle(.plain)
97 .task {
98 await viewModel.loadMoreIfNeeded(current: discovered)
99 }
100 }
101 .themedRow()
102
103 if viewModel.isLoadingMore {
104 HStack {
105 Spacer()
106 ProgressView()
107 Spacer()
108 }
109 .listRowSeparator(.hidden)
110 .themedRow()
111 }
112 }
113 .themedList()
114 .listStyle(.plain)
115 .overlay {
116 if viewModel.isLoading, viewModel.projects.isEmpty {
117 SRHTLoadingStateView(message: "Loading projects…")
118 } else if let error = viewModel.error, viewModel.projects.isEmpty {
119 SRHTErrorStateView(
120 title: "Couldn't Load Projects",
121 message: error,
122 retryAction: { await viewModel.reload() }
123 )
124 } else if viewModel.projects.isEmpty {
125 ContentUnavailableView(
126 "No Public Projects",
127 systemImage: "sparkle.magnifyingglass",
128 description: Text("Public projects on SourceHut will appear here.")
129 )
130 }
131 }
132 .srhtErrorBanner(
133 error: Binding(
134 get: { viewModel.error },
135 set: { viewModel.error = $0 }
136 )
137 )
138 .refreshable {
139 await viewModel.reload()
140 }
141 }
142}
143
144private struct DiscoveredProjectRow: View {
145 let discovered: DiscoveredProject
146
147 private var project: Project { discovered.project }
148
149 var body: some View {
150 VStack(alignment: .leading, spacing: 4) {
151 Text(project.displayName)
152 .font(.headline)
153 .lineLimit(1)
154 Text(discovered.ownerCanonicalName)
155 .font(.caption)
156 .foregroundStyle(.secondary)
157 if let description = project.displayDescription {
158 Text(description)
159 .font(.subheadline)
160 .foregroundStyle(.secondary)
161 .lineLimit(2)
162 }
163 }
164 .padding(.vertical, 2)
165 }
166}
Hutch/Views/Projects/ManageProjectResourcesView.swift added +303
@@ -0,0 +1,303 @@
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}
Hutch/Views/Projects/ProjectDetailView.swift +90
@@ -2,6 +2,7 @@ import SwiftUI
22
33struct ProjectDetailView: View {
44 let project: Project
5 var canManage: Bool = false
56
67 @Environment(AppState.self) private var appState
78 @Environment(\.dismiss) private var dismiss
@@ -10,6 +11,14 @@ struct ProjectDetailView: View {
1011 @State private var isLoading = false
1112 @State private var error: String?
1213 @State private var pinChangeCount = 0
14 @State private var isPresentingEdit = false
15 @State private var isPresentingManage = false
16 @State private var isSavingEdit = false
17 @State private var editError: String?
18
19 private var projectService: ProjectService {
20 ProjectService(client: appState.client)
21 }
1322
1423 private var displayedProject: Project {
1524 detailProject ?? project
@@ -59,6 +68,46 @@ struct ProjectDetailView: View {
5968 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
6069 }
6170 }
71 if canManage {
72 ToolbarItem(placement: .topBarTrailing) {
73 Menu {
74 Button {
75 editError = nil
76 isPresentingEdit = true
77 } label: {
78 Label("Edit Project", systemImage: "pencil")
79 }
80 Button {
81 isPresentingManage = true
82 } label: {
83 Label("Manage Resources", systemImage: "link")
84 }
85 } label: {
86 Image(systemName: "ellipsis.circle")
87 }
88 .accessibilityLabel("Manage project")
89 }
90 }
91 }
92 .sheet(isPresented: $isPresentingEdit) {
93 ProjectFormSheet(
94 title: "Edit Project",
95 confirmationTitle: "Save",
96 isSaving: isSavingEdit,
97 error: editError,
98 includeWebsite: true,
99 initialName: displayedProject.name,
100 initialDescription: displayedProject.description ?? "",
101 initialWebsite: displayedProject.website ?? "",
102 initialTags: displayedProject.tags,
103 initialVisibility: displayedProject.visibility,
104 onSave: { await saveEdits($0) }
105 )
106 }
107 .sheet(isPresented: $isPresentingManage) {
108 ManageProjectResourcesView(project: displayedProject) {
109 await loadProjectIfNeeded(forceRefresh: true)
110 }
62111 }
63112 .task {
64113 await loadProjectIfNeeded()
@@ -69,6 +118,47 @@ struct ProjectDetailView: View {
69118 .srhtErrorBanner(error: $error)
70119 }
71120
121 private func saveEdits(_ values: ProjectFormValues) async -> Bool {
122 guard !isSavingEdit else { return false }
123 isSavingEdit = true
124 editError = nil
125 defer { isSavingEdit = false }
126
127 do {
128 let updated = try await projectService.updateProject(
129 rid: displayedProject.id,
130 name: values.name,
131 description: values.description,
132 website: values.website,
133 visibility: values.visibility,
134 tags: values.tags
135 )
136 // Preserve already-loaded linked resources; the mutation returns metadata only.
137 detailProject = Project(
138 metadata: .init(
139 id: updated.id,
140 name: updated.name,
141 description: updated.description,
142 website: updated.website,
143 visibility: updated.visibility,
144 tags: updated.tags,
145 updated: updated.updated
146 ),
147 resources: .init(
148 mailingLists: displayedProject.mailingLists,
149 sources: displayedProject.sources,
150 trackers: displayedProject.trackers,
151 isFullyLoaded: displayedProject.isFullyLoaded
152 )
153 )
154 await loadProjectIfNeeded(forceRefresh: true)
155 return true
156 } catch {
157 editError = error.userFacingMessage
158 return false
159 }
160 }
161
72162 @ViewBuilder
73163 private var headerSection: some View {
74164 Section {
Hutch/Views/Projects/ProjectFormSheet.swift added +144
@@ -0,0 +1,144 @@
1import SwiftUI
2
3/// Shared create/edit form for hub.sr.ht projects. The parent owns the save
4/// state and performs the mutation via `onSave`, dismissing on success.
5struct ProjectFormSheet: View {
6 let title: String
7 let confirmationTitle: String
8 let isSaving: Bool
9 let error: String?
10 /// `createProject` takes no website; only the edit flow shows the field.
11 let includeWebsite: Bool
12 let onSave: (ProjectFormValues) async -> Bool
13
14 @Environment(\.dismiss) private var dismiss
15 @State private var name: String
16 @State private var description: String
17 @State private var website: String
18 @State private var tags: String
19 @State private var visibility: Visibility
20
21 init(
22 title: String,
23 confirmationTitle: String,
24 isSaving: Bool,
25 error: String?,
26 includeWebsite: Bool,
27 initialName: String = "",
28 initialDescription: String = "",
29 initialWebsite: String = "",
30 initialTags: [String] = [],
31 initialVisibility: Visibility = .publicVisibility,
32 onSave: @escaping (ProjectFormValues) async -> Bool
33 ) {
34 self.title = title
35 self.confirmationTitle = confirmationTitle
36 self.isSaving = isSaving
37 self.error = error
38 self.includeWebsite = includeWebsite
39 self.onSave = onSave
40 _name = State(initialValue: initialName)
41 _description = State(initialValue: initialDescription)
42 _website = State(initialValue: initialWebsite)
43 _tags = State(initialValue: initialTags.joined(separator: ", "))
44 _visibility = State(initialValue: initialVisibility)
45 }
46
47 private var trimmedName: String {
48 name.trimmingCharacters(in: .whitespacesAndNewlines)
49 }
50
51 var body: some View {
52 NavigationStack {
53 Form {
54 Section("Project Details") {
55 TextField("Project name", text: $name)
56 .textInputAutocapitalization(.never)
57 .autocorrectionDisabled()
58 .themedRow()
59 TextField("Description (optional)", text: $description, axis: .vertical)
60 .lineLimit(2...4)
61 .themedRow()
62 if includeWebsite {
63 TextField("Website (optional)", text: $website)
64 .textInputAutocapitalization(.never)
65 .autocorrectionDisabled()
66 .keyboardType(.URL)
67 .themedRow()
68 }
69 Picker("Visibility", selection: $visibility) {
70 Text("Public").tag(Visibility.publicVisibility)
71 Text("Unlisted").tag(Visibility.unlisted)
72 Text("Private").tag(Visibility.privateVisibility)
73 }
74 .themedRow()
75 }
76
77 Section {
78 TextField("Tags (comma separated)", text: $tags)
79 .textInputAutocapitalization(.never)
80 .autocorrectionDisabled()
81 .themedRow()
82 } footer: {
83 Text("Separate tags with commas.")
84 }
85
86 if let error, !error.isEmpty {
87 Section {
88 Text(error)
89 .foregroundStyle(.red)
90 .themedRow()
91 }
92 }
93 }
94 .themedList()
95 .navigationTitle(title)
96 .navigationBarTitleDisplayMode(.inline)
97 .toolbar {
98 ToolbarItem(placement: .cancellationAction) {
99 Button("Cancel") { dismiss() }
100 }
101 ToolbarItem(placement: .confirmationAction) {
102 Button {
103 Task {
104 let values = ProjectFormValues(
105 name: trimmedName,
106 description: description.trimmingCharacters(in: .whitespacesAndNewlines),
107 website: website.trimmingCharacters(in: .whitespacesAndNewlines),
108 visibility: visibility,
109 tags: Self.parseTags(tags)
110 )
111 if await onSave(values) {
112 dismiss()
113 }
114 }
115 } label: {
116 if isSaving {
117 ProgressView().controlSize(.small)
118 } else {
119 Text(confirmationTitle)
120 }
121 }
122 .disabled(trimmedName.isEmpty || isSaving)
123 }
124 }
125 }
126 }
127
128 static func parseTags(_ raw: String) -> [String] {
129 var seen = Set<String>()
130 return raw
131 .split(whereSeparator: { $0 == "," || $0.isNewline })
132 .map { $0.trimmingCharacters(in: .whitespaces) }
133 .filter { !$0.isEmpty }
134 .filter { seen.insert($0.lowercased()).inserted }
135 }
136}
137
138struct ProjectFormValues: Sendable {
139 let name: String
140 let description: String
141 let website: String
142 let visibility: Visibility
143 let tags: [String]
144}
Hutch/Views/Projects/ProjectsListView.swift +56 −2
@@ -5,15 +5,38 @@ import SwiftUI
55final class ProjectsListViewModel {
66 private(set) var projects: [Project] = []
77 private(set) var isLoading = false
8 private(set) var isSaving = false
89 var error: String?
10 var saveError: String?
911 var searchText = ""
1012
11 private let service: ProjectService
13 let service: ProjectService
1214
1315 init(service: ProjectService) {
1416 self.service = service
1517 }
1618
19 func createProject(_ values: ProjectFormValues) async -> Bool {
20 guard !isSaving else { return false }
21 isSaving = true
22 saveError = nil
23 defer { isSaving = false }
24
25 do {
26 _ = try await service.createProject(
27 name: values.name,
28 visibility: values.visibility,
29 description: values.description.isEmpty ? nil : values.description,
30 tags: values.tags
31 )
32 await loadProjects(forceRefresh: true)
33 return true
34 } catch {
35 saveError = error.userFacingMessage
36 return false
37 }
38 }
39
1740 var filteredProjects: [Project] {
1841 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1942 guard !query.isEmpty else { return projects }
@@ -46,11 +69,42 @@ final class ProjectsListViewModel {
4669struct ProjectsListView: View {
4770 @Environment(AppState.self) private var appState
4871 @State private var viewModel: ProjectsListViewModel?
72 @State private var isPresentingCreate = false
4973
5074 var body: some View {
5175 Group {
5276 if let viewModel {
5377 content(viewModel)
78 .toolbar {
79 ToolbarItem(placement: .topBarTrailing) {
80 NavigationLink {
81 DiscoverProjectsView()
82 } label: {
83 Image(systemName: "sparkle.magnifyingglass")
84 }
85 .accessibilityLabel("Discover public projects")
86 }
87 if appState.currentUser != nil {
88 ToolbarItem(placement: .topBarTrailing) {
89 Button {
90 isPresentingCreate = true
91 } label: {
92 Image(systemName: "plus")
93 }
94 .accessibilityLabel("Create project")
95 }
96 }
97 }
98 .sheet(isPresented: $isPresentingCreate) {
99 ProjectFormSheet(
100 title: "New Project",
101 confirmationTitle: "Create",
102 isSaving: viewModel.isSaving,
103 error: viewModel.saveError,
104 includeWebsite: false,
105 onSave: { await viewModel.createProject($0) }
106 )
107 }
54108 } else {
55109 SRHTLoadingStateView(message: "Loading projects…")
56110 }
@@ -70,7 +124,7 @@ struct ProjectsListView: View {
70124 List {
71125 ForEach(viewModel.filteredProjects) { project in
72126 NavigationLink {
73 ProjectDetailView(project: project)
127 ProjectDetailView(project: project, canManage: true)
74128 } label: {
75129 ProjectListRow(project: project)
76130 }
HutchTests/ProjectFormTests.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct ProjectFormTests {
6 @Test
7 func parsesTagsSplittingAndTrimming() {
8 #expect(ProjectFormSheet.parseTags("swift, ios , ") == ["swift", "ios"])
9 #expect(ProjectFormSheet.parseTags("a,b,c") == ["a", "b", "c"])
10 }
11
12 @Test
13 func parseTagsDeduplicatesCaseInsensitively() {
14 #expect(ProjectFormSheet.parseTags("Swift, swift, SWIFT") == ["Swift"])
15 }
16
17 @Test
18 func parseTagsEmptyInputYieldsEmpty() {
19 #expect(ProjectFormSheet.parseTags(" ").isEmpty)
20 #expect(ProjectFormSheet.parseTags("").isEmpty)
21 }
22
23 @Test
24 func linkableResourceDisplayNameCombinesOwnerAndName() {
25 let resource = LinkableResource(rid: "r1", name: "hutch", ownerCanonicalName: "~alice", kind: .source)
26 #expect(resource.displayName == "~alice/hutch")
27 #expect(resource.id == "r1")
28 }
29
30 @Test
31 func discoveredProjectIDMatchesProject() {
32 let project = Project(
33 metadata: .init(id: "p1", name: "Hutch", description: nil, website: nil, visibility: .publicVisibility, tags: [], updated: .now),
34 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
35 )
36 let discovered = DiscoveredProject(project: project, ownerCanonicalName: "~alice")
37 #expect(discovered.id == "p1")
38 }
39}