summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 04:12:50 -0500
committerChristian Cleberg <[email protected]>2026-08-07 04:12:50 -0500
commit1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8 (patch)
tree25cf810fa0e9a0ab343b60b59c26d8cc096c9a98
parentdb40d346c28800a1eec384a5e4f3d7d090198e4d (diff)
downloadhutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.tar.gz
hutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.tar.bz2
hutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.zip
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).
-rw-r--r--Hutch/Networking/ProjectService.swift469
-rw-r--r--Hutch/Views/Projects/DiscoverProjectsView.swift166
-rw-r--r--Hutch/Views/Projects/ManageProjectResourcesView.swift303
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift90
-rw-r--r--Hutch/Views/Projects/ProjectFormSheet.swift144
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift58
-rw-r--r--HutchTests/ProjectFormTests.swift39
7 files changed, 1267 insertions, 2 deletions
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 91dc339..10af2fb 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -198,6 +198,170 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
}
}
+/// A public project surfaced by discovery, carrying its owner for display.
+struct DiscoveredProject: Identifiable, Hashable, Sendable {
+ let project: Project
+ let ownerCanonicalName: String
+
+ var id: String { project.id }
+}
+
+struct DiscoveredProjectsPage: Sendable {
+ let projects: [DiscoveredProject]
+ let cursor: String?
+}
+
+private struct PublicProjectsResponse: Decodable, Sendable {
+ let projects: PublicProjectPage
+}
+
+private struct PublicProjectPage: Decodable, Sendable {
+ let results: [PublicProjectPayload]
+ let cursor: String?
+
+ init(from decoder: any Decoder) throws {
+ enum CodingKeys: String, CodingKey { case results, cursor }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ results = try container.decodeIfPresent([PublicProjectPayload].self, forKey: .results) ?? []
+ cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
+}
+
+private struct PublicProjectPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+ let updated: Date
+ let owner: Entity
+
+ init(from decoder: any Decoder) throws {
+ enum CodingKeys: String, CodingKey {
+ case rid, name, description, website, visibility, tags, updated, owner
+ }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ website = try container.decodeIfPresent(String.self, forKey: .website)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+ owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+ }
+}
+
+/// A resource the current user can link to a project (#15 add flow).
+struct LinkableResource: Identifiable, Hashable, Sendable {
+ enum Kind: Sendable { case source, tracker, mailingList }
+
+ let rid: String
+ let name: String
+ let ownerCanonicalName: String
+ let kind: Kind
+
+ var id: String { rid }
+ var displayName: String { "\(ownerCanonicalName)/\(name)" }
+}
+
+private struct CandidatePayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let owner: Entity?
+}
+
+private struct CandidatePage: Decodable, Sendable {
+ let results: [CandidatePayload]
+ let cursor: String?
+
+ init(from decoder: any Decoder) throws {
+ enum CodingKeys: String, CodingKey { case results, cursor }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ results = try container.decodeIfPresent([CandidatePayload].self, forKey: .results) ?? []
+ cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
+}
+
+private struct RepoCandidatesResponse: Decodable, Sendable {
+ let repositories: CandidatePage
+}
+
+private struct TrackerCandidatesResponse: Decodable, Sendable {
+ let trackers: CandidatePage
+}
+
+private struct ListSubscriptionPayload: Decodable, Sendable {
+ let list: CandidatePayload?
+}
+
+private struct ListSubscriptionPage: Decodable, Sendable {
+ let results: [ListSubscriptionPayload]
+ let cursor: String?
+
+ init(from decoder: any Decoder) throws {
+ enum CodingKeys: String, CodingKey { case results, cursor }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ results = try container.decodeIfPresent([ListSubscriptionPayload].self, forKey: .results) ?? []
+ cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
+}
+
+private struct ListCandidatesResponse: Decodable, Sendable {
+ let subscriptions: ListSubscriptionPage
+}
+
+/// Link/unlink mutations only need success; the returned resource is ignored.
+private struct LinkMutationResponse: Decodable, Sendable {}
+
+private struct CreateProjectResponse: Decodable, Sendable {
+ let createProject: MutatedProjectPayload?
+}
+
+private struct UpdateProjectResponse: Decodable, Sendable {
+ let updateProject: MutatedProjectPayload?
+}
+
+private struct MutatedProjectPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+ let updated: Date
+
+ init(from decoder: any Decoder) throws {
+ enum CodingKeys: String, CodingKey {
+ case rid, name, description, website, visibility, tags, updated
+ }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ website = try container.decodeIfPresent(String.self, forKey: .website)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+ }
+
+ var project: Project {
+ Project(
+ metadata: .init(
+ id: rid,
+ name: name,
+ description: description,
+ website: website,
+ visibility: visibility,
+ tags: tags,
+ updated: updated
+ ),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
+ )
+ }
+}
+
struct ProjectService: Sendable {
private let client: SRHTClient
@@ -265,6 +429,91 @@ struct ProjectService: Sendable {
}
"""
+ private static let publicProjectsQuery = """
+ query publicProjects($cursor: Cursor) {
+ projects(cursor: $cursor) {
+ results {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createProjectMutation = """
+ mutation createProject($name: String!, $visibility: Visibility!, $description: String, $tags: [String!]) {
+ createProject(name: $name, visibility: $visibility, description: $description, tags: $tags) {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ updated
+ }
+ }
+ """
+
+ private static let updateProjectMutation = """
+ mutation updateProject($rid: ID!, $input: ProjectInput!) {
+ updateProject(rid: $rid, input: $input) {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ updated
+ }
+ }
+ """
+
+ private static func linkMutation(field: String, resourceParam: String) -> String {
+ """
+ mutation link($projectID: ID!, $resourceID: ID!) {
+ \(field)(projectID: $projectID, \(resourceParam): $resourceID) { rid }
+ }
+ """
+ }
+
+ private static let repositoriesCandidatesQuery = """
+ query repositories($cursor: Cursor) {
+ repositories(cursor: $cursor) {
+ results { rid name owner { canonicalName } }
+ cursor
+ }
+ }
+ """
+
+ private static let trackersCandidatesQuery = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results { rid name owner { canonicalName } }
+ cursor
+ }
+ }
+ """
+
+ private static let listCandidatesQuery = """
+ query subscriptions($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list { rid name owner { canonicalName } }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
init(client: SRHTClient) {
self.client = client
}
@@ -277,6 +526,226 @@ struct ProjectService: Sendable {
try await fetchProjectDetailPayload(rid: rid)
}
+ // MARK: - Discovery (#12)
+
+ /// Lists public projects across all users. Not cached — discovery is
+ /// browsed live and paginated by the caller.
+ func fetchPublicProjects(cursor: String? = nil) async throws -> DiscoveredProjectsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.publicProjectsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: PublicProjectsResponse.self
+ )
+
+ let projects = response.projects.results.map { payload in
+ DiscoveredProject(
+ project: Project(
+ metadata: .init(
+ id: payload.rid,
+ name: payload.name,
+ description: payload.description,
+ website: payload.website,
+ visibility: payload.visibility,
+ tags: payload.tags,
+ updated: payload.updated
+ ),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
+ ),
+ ownerCanonicalName: payload.owner.canonicalName
+ )
+ }
+ return DiscoveredProjectsPage(projects: projects, cursor: response.projects.cursor)
+ }
+
+ // MARK: - Mutations (#13, #14, #15)
+
+ func createProject(
+ name: String,
+ visibility: Visibility,
+ description: String?,
+ tags: [String]
+ ) async throws -> Project {
+ var variables: [String: any Sendable] = ["name": name, "visibility": visibility.rawValue]
+ if let description, !description.isEmpty {
+ variables["description"] = description
+ }
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.createProjectMutation,
+ variables: variables,
+ responseType: CreateProjectResponse.self
+ )
+ await invalidateProjectCaches()
+
+ guard let payload = response.createProject else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing createProject payload"))
+ )
+ }
+ return payload.project
+ }
+
+ /// Updates a project. Only non-nil fields are sent; `description`/`website`
+ /// pass an empty string to clear the field.
+ func updateProject(
+ rid: String,
+ name: String? = nil,
+ description: String? = nil,
+ website: String? = nil,
+ visibility: Visibility? = nil,
+ tags: [String]? = nil
+ ) async throws -> Project {
+ var input: [String: any Sendable] = [:]
+ if let name {
+ input["name"] = name
+ }
+ if let description {
+ input["description"] = description
+ }
+ if let website {
+ input["website"] = website
+ }
+ if let visibility {
+ input["visibility"] = visibility.rawValue
+ }
+ if let tags {
+ input["tags"] = tags
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.updateProjectMutation,
+ variables: ["rid": rid, "input": input],
+ responseType: UpdateProjectResponse.self
+ )
+ await invalidateProjectCaches()
+
+ guard let payload = response.updateProject else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing updateProject payload"))
+ )
+ }
+ return payload.project
+ }
+
+ func linkSource(projectID: String, sourceRepoID: String) async throws {
+ try await runLink(field: "linkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
+ }
+
+ func unlinkSource(projectID: String, sourceRepoID: String) async throws {
+ try await runLink(field: "unlinkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
+ }
+
+ func linkTracker(projectID: String, trackerID: String) async throws {
+ try await runLink(field: "linkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
+ }
+
+ func unlinkTracker(projectID: String, trackerID: String) async throws {
+ try await runLink(field: "unlinkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
+ }
+
+ func linkMailingList(projectID: String, listID: String) async throws {
+ try await runLink(field: "linkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
+ }
+
+ func unlinkMailingList(projectID: String, listID: String) async throws {
+ try await runLink(field: "unlinkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
+ }
+
+ private func runLink(field: String, resourceParam: String, projectID: String, resourceID: String) async throws {
+ _ = try await client.execute(
+ service: .hub,
+ query: Self.linkMutation(field: field, resourceParam: resourceParam),
+ variables: ["projectID": projectID, "resourceID": resourceID],
+ responseType: LinkMutationResponse.self
+ )
+ await invalidateProjectCaches()
+ }
+
+ private func invalidateProjectCaches() async {
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.hub.rawValue))
+ }
+
+ // MARK: - Linkable resource candidates (#15 add flow)
+
+ /// Repositories the user can link (git and hg), sorted by display name.
+ func fetchLinkableSources() async throws -> [LinkableResource] {
+ async let git = fetchRepoCandidates(service: .git)
+ async let hg = fetchRepoCandidates(service: .hg)
+ return dedupeSorted(try await git + (try await hg))
+ }
+
+ func fetchLinkableTrackers() async throws -> [LinkableResource] {
+ var results: [LinkableResource] = []
+ var cursor: String?
+ repeat {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackersCandidatesQuery,
+ variables: cursor.map { ["cursor": $0] },
+ responseType: TrackerCandidatesResponse.self
+ )
+ results.append(contentsOf: response.trackers.results.map {
+ LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .tracker)
+ })
+ cursor = response.trackers.cursor
+ } while cursor != nil
+ return dedupeSorted(results)
+ }
+
+ func fetchLinkableMailingLists() async throws -> [LinkableResource] {
+ var results: [LinkableResource] = []
+ var cursor: String?
+ repeat {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.listCandidatesQuery,
+ variables: cursor.map { ["cursor": $0] },
+ responseType: ListCandidatesResponse.self
+ )
+ results.append(contentsOf: response.subscriptions.results.compactMap(\.list).map {
+ LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .mailingList)
+ })
+ cursor = response.subscriptions.cursor
+ } while cursor != nil
+ return dedupeSorted(results)
+ }
+
+ private func fetchRepoCandidates(service: SRHTService) async throws -> [LinkableResource] {
+ var results: [LinkableResource] = []
+ var cursor: String?
+ repeat {
+ let response = try await client.execute(
+ service: service,
+ query: Self.repositoriesCandidatesQuery,
+ variables: cursor.map { ["cursor": $0] },
+ responseType: RepoCandidatesResponse.self
+ )
+ results.append(contentsOf: response.repositories.results.map {
+ LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .source)
+ })
+ cursor = response.repositories.cursor
+ } while cursor != nil
+ return results
+ }
+
+ private func dedupeSorted(_ items: [LinkableResource]) -> [LinkableResource] {
+ var seen = Set<String>()
+ return items
+ .filter { seen.insert($0.rid).inserted }
+ .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+ }
+
private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] {
var results: [ProjectSummaryPayload] = []
var cursor: String?
diff --git a/Hutch/Views/Projects/DiscoverProjectsView.swift b/Hutch/Views/Projects/DiscoverProjectsView.swift
new file mode 100644
index 0000000..fab165e
--- /dev/null
+++ b/Hutch/Views/Projects/DiscoverProjectsView.swift
@@ -0,0 +1,166 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class DiscoverProjectsViewModel {
+ private(set) var projects: [DiscoveredProject] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ var error: String?
+
+ private var cursor: String?
+ private var canLoadMore = true
+ private let service: ProjectService
+
+ init(service: ProjectService) {
+ self.service = service
+ }
+
+ func loadInitial() async {
+ guard projects.isEmpty, !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let page = try await service.fetchPublicProjects(cursor: nil)
+ projects = page.projects
+ cursor = page.cursor
+ canLoadMore = page.cursor != nil
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ func reload() async {
+ cursor = nil
+ canLoadMore = true
+ projects = []
+ await loadInitial()
+ }
+
+ func loadMoreIfNeeded(current item: DiscoveredProject) async {
+ guard canLoadMore, !isLoadingMore, !isLoading else { return }
+ guard let index = projects.firstIndex(of: item), index >= projects.count - 3 else { return }
+
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+
+ do {
+ let page = try await service.fetchPublicProjects(cursor: cursor)
+ let existing = Set(projects.map(\.id))
+ projects.append(contentsOf: page.projects.filter { !existing.contains($0.id) })
+ cursor = page.cursor
+ canLoadMore = page.cursor != nil
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct DiscoverProjectsView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: DiscoverProjectsViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading projects…")
+ }
+ }
+ .navigationTitle("Discover")
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ if viewModel == nil {
+ let vm = DiscoverProjectsViewModel(service: ProjectService(client: appState.client))
+ viewModel = vm
+ await vm.loadInitial()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: DiscoverProjectsViewModel) -> some View {
+ List {
+ ForEach(viewModel.projects) { discovered in
+ NavigationLink {
+ ProjectDetailView(
+ project: discovered.project,
+ canManage: discovered.ownerCanonicalName == appState.currentUser?.canonicalName
+ )
+ } label: {
+ DiscoveredProjectRow(discovered: discovered)
+ }
+ .buttonStyle(.plain)
+ .task {
+ await viewModel.loadMoreIfNeeded(current: discovered)
+ }
+ }
+ .themedRow()
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ .themedRow()
+ }
+ }
+ .themedList()
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.projects.isEmpty {
+ SRHTLoadingStateView(message: "Loading projects…")
+ } else if let error = viewModel.error, viewModel.projects.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Projects",
+ message: error,
+ retryAction: { await viewModel.reload() }
+ )
+ } else if viewModel.projects.isEmpty {
+ ContentUnavailableView(
+ "No Public Projects",
+ systemImage: "sparkle.magnifyingglass",
+ description: Text("Public projects on SourceHut will appear here.")
+ )
+ }
+ }
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
+ .refreshable {
+ await viewModel.reload()
+ }
+ }
+}
+
+private struct DiscoveredProjectRow: View {
+ let discovered: DiscoveredProject
+
+ private var project: Project { discovered.project }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.displayName)
+ .font(.headline)
+ .lineLimit(1)
+ Text(discovered.ownerCanonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let description = project.displayDescription {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Projects/ManageProjectResourcesView.swift b/Hutch/Views/Projects/ManageProjectResourcesView.swift
new file mode 100644
index 0000000..5769d17
--- /dev/null
+++ b/Hutch/Views/Projects/ManageProjectResourcesView.swift
@@ -0,0 +1,303 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class ManageProjectResourcesViewModel {
+ private(set) var sources: [Project.SourceRepo]
+ private(set) var trackers: [Project.Tracker]
+ private(set) var mailingLists: [Project.MailingList]
+ private(set) var busyID: String?
+ var error: String?
+
+ let projectID: String
+ let projectName: String
+ private let service: ProjectService
+ private let onChange: () async -> Void
+
+ init(project: Project, service: ProjectService, onChange: @escaping () async -> Void) {
+ projectID = project.id
+ projectName = project.displayName
+ sources = project.sources
+ trackers = project.trackers
+ mailingLists = project.mailingLists
+ self.service = service
+ self.onChange = onChange
+ }
+
+ /// rids already linked, so the candidate pickers can hide them.
+ var linkedRIDs: Set<String> {
+ Set(sources.map(\.id) + trackers.map(\.id) + mailingLists.map(\.id))
+ }
+
+ func unlink(source: Project.SourceRepo) async {
+ await mutate(id: source.id) {
+ try await self.service.unlinkSource(projectID: self.projectID, sourceRepoID: source.id)
+ self.sources.removeAll { $0.id == source.id }
+ }
+ }
+
+ func unlink(tracker: Project.Tracker) async {
+ await mutate(id: tracker.id) {
+ try await self.service.unlinkTracker(projectID: self.projectID, trackerID: tracker.id)
+ self.trackers.removeAll { $0.id == tracker.id }
+ }
+ }
+
+ func unlink(mailingList: Project.MailingList) async {
+ await mutate(id: mailingList.id) {
+ try await self.service.unlinkMailingList(projectID: self.projectID, listID: mailingList.id)
+ self.mailingLists.removeAll { $0.id == mailingList.id }
+ }
+ }
+
+ func link(_ resource: LinkableResource) async {
+ await mutate(id: resource.rid) {
+ switch resource.kind {
+ case .source:
+ try await self.service.linkSource(projectID: self.projectID, sourceRepoID: resource.rid)
+ case .tracker:
+ try await self.service.linkTracker(projectID: self.projectID, trackerID: resource.rid)
+ case .mailingList:
+ try await self.service.linkMailingList(projectID: self.projectID, listID: resource.rid)
+ }
+ try await self.reload()
+ }
+ }
+
+ func candidates(for kind: LinkableResource.Kind) async throws -> [LinkableResource] {
+ let all: [LinkableResource]
+ switch kind {
+ case .source: all = try await service.fetchLinkableSources()
+ case .tracker: all = try await service.fetchLinkableTrackers()
+ case .mailingList: all = try await service.fetchLinkableMailingLists()
+ }
+ let linked = linkedRIDs
+ return all.filter { !linked.contains($0.rid) }
+ }
+
+ private func reload() async throws {
+ let project = try await service.fetchProjectDetail(rid: projectID)
+ sources = project.sources
+ trackers = project.trackers
+ mailingLists = project.mailingLists
+ }
+
+ private func mutate(id: String, _ work: @escaping () async throws -> Void) async {
+ guard busyID == nil else { return }
+ busyID = id
+ error = nil
+ defer { busyID = nil }
+ do {
+ try await work()
+ await onChange()
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct ManageProjectResourcesView: View {
+ let project: Project
+ let onChange: () async -> Void
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: ManageProjectResourcesViewModel?
+ @State private var addKind: LinkableResource.Kind?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading…")
+ }
+ }
+ .navigationTitle("Linked Resources")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ ToolbarItem(placement: .topBarLeading) {
+ Menu {
+ Button("Add Repository") { addKind = .source }
+ Button("Add Tracker") { addKind = .tracker }
+ Button("Add Mailing List") { addKind = .mailingList }
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add linked resource")
+ }
+ }
+ .sheet(item: Binding(get: { addKind.map { AddKind(kind: $0) } }, set: { addKind = $0?.kind })) { wrapper in
+ if let viewModel {
+ LinkableResourcePicker(kind: wrapper.kind, viewModel: viewModel)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ viewModel = ManageProjectResourcesViewModel(
+ project: project,
+ service: ProjectService(client: appState.client),
+ onChange: onChange
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: ManageProjectResourcesViewModel) -> some View {
+ List {
+ resourceSection(
+ title: "Repositories",
+ items: viewModel.sources,
+ busyID: viewModel.busyID,
+ label: { "\($0.ownerUsername)/\($0.displayName)" },
+ onDelete: { await viewModel.unlink(source: $0) }
+ )
+ resourceSection(
+ title: "Trackers",
+ items: viewModel.trackers,
+ busyID: viewModel.busyID,
+ label: { "\($0.ownerUsername)/\($0.displayName)" },
+ onDelete: { await viewModel.unlink(tracker: $0) }
+ )
+ resourceSection(
+ title: "Mailing Lists",
+ items: viewModel.mailingLists,
+ busyID: viewModel.busyID,
+ label: { "\($0.ownerUsername)/\($0.displayName)" },
+ onDelete: { await viewModel.unlink(mailingList: $0) }
+ )
+
+ if viewModel.sources.isEmpty, viewModel.trackers.isEmpty, viewModel.mailingLists.isEmpty {
+ Section {
+ Text("No linked resources. Use + to add repositories, trackers, or mailing lists.")
+ .foregroundStyle(.secondary)
+ .themedRow()
+ }
+ }
+ }
+ .themedList()
+ .srhtErrorBanner(
+ error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 })
+ )
+ }
+
+ @ViewBuilder
+ private func resourceSection<Item: Identifiable>(
+ title: String,
+ items: [Item],
+ busyID: String?,
+ label: @escaping (Item) -> String,
+ onDelete: @escaping (Item) async -> Void
+ ) -> some View where Item.ID == String {
+ if !items.isEmpty {
+ Section(title) {
+ ForEach(items) { item in
+ HStack {
+ Text(label(item))
+ .font(.body.monospaced())
+ .lineLimit(1)
+ Spacer()
+ if busyID == item.id {
+ ProgressView().controlSize(.small)
+ }
+ }
+ .themedRow()
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button(role: .destructive) {
+ Task { await onDelete(item) }
+ } label: {
+ Label("Unlink", systemImage: "link.badge.minus")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct AddKind: Identifiable {
+ let kind: LinkableResource.Kind
+ var id: String {
+ switch kind {
+ case .source: "source"
+ case .tracker: "tracker"
+ case .mailingList: "mailingList"
+ }
+ }
+}
+
+private struct LinkableResourcePicker: View {
+ let kind: LinkableResource.Kind
+ let viewModel: ManageProjectResourcesViewModel
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var candidates: [LinkableResource] = []
+ @State private var isLoading = true
+ @State private var error: String?
+
+ private var title: String {
+ switch kind {
+ case .source: "Add Repository"
+ case .tracker: "Add Tracker"
+ case .mailingList: "Add Mailing List"
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ SRHTLoadingStateView(message: "Loading…")
+ } else if let error {
+ SRHTErrorStateView(title: "Couldn't Load", message: error, retryAction: { await load() })
+ } else if candidates.isEmpty {
+ ContentUnavailableView(
+ "Nothing to Add",
+ systemImage: "checkmark.circle",
+ description: Text("There are no more resources of this type to link.")
+ )
+ } else {
+ List(candidates) { candidate in
+ Button {
+ Task {
+ await viewModel.link(candidate)
+ dismiss()
+ }
+ } label: {
+ Text(candidate.displayName)
+ .font(.body.monospaced())
+ .lineLimit(1)
+ }
+ .themedRow()
+ }
+ .themedList()
+ }
+ }
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ .task { await load() }
+ }
+
+ private func load() async {
+ isLoading = true
+ error = nil
+ do {
+ candidates = try await viewModel.candidates(for: kind)
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ isLoading = false
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index c30ec88..0685a83 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct ProjectDetailView: View {
let project: Project
+ var canManage: Bool = false
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@@ -10,6 +11,14 @@ struct ProjectDetailView: View {
@State private var isLoading = false
@State private var error: String?
@State private var pinChangeCount = 0
+ @State private var isPresentingEdit = false
+ @State private var isPresentingManage = false
+ @State private var isSavingEdit = false
+ @State private var editError: String?
+
+ private var projectService: ProjectService {
+ ProjectService(client: appState.client)
+ }
private var displayedProject: Project {
detailProject ?? project
@@ -59,6 +68,46 @@ struct ProjectDetailView: View {
.accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
}
}
+ if canManage {
+ ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Button {
+ editError = nil
+ isPresentingEdit = true
+ } label: {
+ Label("Edit Project", systemImage: "pencil")
+ }
+ Button {
+ isPresentingManage = true
+ } label: {
+ Label("Manage Resources", systemImage: "link")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .accessibilityLabel("Manage project")
+ }
+ }
+ }
+ .sheet(isPresented: $isPresentingEdit) {
+ ProjectFormSheet(
+ title: "Edit Project",
+ confirmationTitle: "Save",
+ isSaving: isSavingEdit,
+ error: editError,
+ includeWebsite: true,
+ initialName: displayedProject.name,
+ initialDescription: displayedProject.description ?? "",
+ initialWebsite: displayedProject.website ?? "",
+ initialTags: displayedProject.tags,
+ initialVisibility: displayedProject.visibility,
+ onSave: { await saveEdits($0) }
+ )
+ }
+ .sheet(isPresented: $isPresentingManage) {
+ ManageProjectResourcesView(project: displayedProject) {
+ await loadProjectIfNeeded(forceRefresh: true)
+ }
}
.task {
await loadProjectIfNeeded()
@@ -69,6 +118,47 @@ struct ProjectDetailView: View {
.srhtErrorBanner(error: $error)
}
+ private func saveEdits(_ values: ProjectFormValues) async -> Bool {
+ guard !isSavingEdit else { return false }
+ isSavingEdit = true
+ editError = nil
+ defer { isSavingEdit = false }
+
+ do {
+ let updated = try await projectService.updateProject(
+ rid: displayedProject.id,
+ name: values.name,
+ description: values.description,
+ website: values.website,
+ visibility: values.visibility,
+ tags: values.tags
+ )
+ // Preserve already-loaded linked resources; the mutation returns metadata only.
+ detailProject = Project(
+ metadata: .init(
+ id: updated.id,
+ name: updated.name,
+ description: updated.description,
+ website: updated.website,
+ visibility: updated.visibility,
+ tags: updated.tags,
+ updated: updated.updated
+ ),
+ resources: .init(
+ mailingLists: displayedProject.mailingLists,
+ sources: displayedProject.sources,
+ trackers: displayedProject.trackers,
+ isFullyLoaded: displayedProject.isFullyLoaded
+ )
+ )
+ await loadProjectIfNeeded(forceRefresh: true)
+ return true
+ } catch {
+ editError = error.userFacingMessage
+ return false
+ }
+ }
+
@ViewBuilder
private var headerSection: some View {
Section {
diff --git a/Hutch/Views/Projects/ProjectFormSheet.swift b/Hutch/Views/Projects/ProjectFormSheet.swift
new file mode 100644
index 0000000..b05ae66
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectFormSheet.swift
@@ -0,0 +1,144 @@
+import SwiftUI
+
+/// Shared create/edit form for hub.sr.ht projects. The parent owns the save
+/// state and performs the mutation via `onSave`, dismissing on success.
+struct ProjectFormSheet: View {
+ let title: String
+ let confirmationTitle: String
+ let isSaving: Bool
+ let error: String?
+ /// `createProject` takes no website; only the edit flow shows the field.
+ let includeWebsite: Bool
+ let onSave: (ProjectFormValues) async -> Bool
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name: String
+ @State private var description: String
+ @State private var website: String
+ @State private var tags: String
+ @State private var visibility: Visibility
+
+ init(
+ title: String,
+ confirmationTitle: String,
+ isSaving: Bool,
+ error: String?,
+ includeWebsite: Bool,
+ initialName: String = "",
+ initialDescription: String = "",
+ initialWebsite: String = "",
+ initialTags: [String] = [],
+ initialVisibility: Visibility = .publicVisibility,
+ onSave: @escaping (ProjectFormValues) async -> Bool
+ ) {
+ self.title = title
+ self.confirmationTitle = confirmationTitle
+ self.isSaving = isSaving
+ self.error = error
+ self.includeWebsite = includeWebsite
+ self.onSave = onSave
+ _name = State(initialValue: initialName)
+ _description = State(initialValue: initialDescription)
+ _website = State(initialValue: initialWebsite)
+ _tags = State(initialValue: initialTags.joined(separator: ", "))
+ _visibility = State(initialValue: initialVisibility)
+ }
+
+ private var trimmedName: String {
+ name.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Project Details") {
+ TextField("Project name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .themedRow()
+ TextField("Description (optional)", text: $description, axis: .vertical)
+ .lineLimit(2...4)
+ .themedRow()
+ if includeWebsite {
+ TextField("Website (optional)", text: $website)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ .themedRow()
+ }
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.publicVisibility)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.privateVisibility)
+ }
+ .themedRow()
+ }
+
+ Section {
+ TextField("Tags (comma separated)", text: $tags)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .themedRow()
+ } footer: {
+ Text("Separate tags with commas.")
+ }
+
+ if let error, !error.isEmpty {
+ Section {
+ Text(error)
+ .foregroundStyle(.red)
+ .themedRow()
+ }
+ }
+ }
+ .themedList()
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let values = ProjectFormValues(
+ name: trimmedName,
+ description: description.trimmingCharacters(in: .whitespacesAndNewlines),
+ website: website.trimmingCharacters(in: .whitespacesAndNewlines),
+ visibility: visibility,
+ tags: Self.parseTags(tags)
+ )
+ if await onSave(values) {
+ dismiss()
+ }
+ }
+ } label: {
+ if isSaving {
+ ProgressView().controlSize(.small)
+ } else {
+ Text(confirmationTitle)
+ }
+ }
+ .disabled(trimmedName.isEmpty || isSaving)
+ }
+ }
+ }
+ }
+
+ static func parseTags(_ raw: String) -> [String] {
+ var seen = Set<String>()
+ return raw
+ .split(whereSeparator: { $0 == "," || $0.isNewline })
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ .filter { seen.insert($0.lowercased()).inserted }
+ }
+}
+
+struct ProjectFormValues: Sendable {
+ let name: String
+ let description: String
+ let website: String
+ let visibility: Visibility
+ let tags: [String]
+}
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 783417c..1a34346 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -5,15 +5,38 @@ import SwiftUI
final class ProjectsListViewModel {
private(set) var projects: [Project] = []
private(set) var isLoading = false
+ private(set) var isSaving = false
var error: String?
+ var saveError: String?
var searchText = ""
- private let service: ProjectService
+ let service: ProjectService
init(service: ProjectService) {
self.service = service
}
+ func createProject(_ values: ProjectFormValues) async -> Bool {
+ guard !isSaving else { return false }
+ isSaving = true
+ saveError = nil
+ defer { isSaving = false }
+
+ do {
+ _ = try await service.createProject(
+ name: values.name,
+ visibility: values.visibility,
+ description: values.description.isEmpty ? nil : values.description,
+ tags: values.tags
+ )
+ await loadProjects(forceRefresh: true)
+ return true
+ } catch {
+ saveError = error.userFacingMessage
+ return false
+ }
+ }
+
var filteredProjects: [Project] {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else { return projects }
@@ -46,11 +69,42 @@ final class ProjectsListViewModel {
struct ProjectsListView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: ProjectsListViewModel?
+ @State private var isPresentingCreate = false
var body: some View {
Group {
if let viewModel {
content(viewModel)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ NavigationLink {
+ DiscoverProjectsView()
+ } label: {
+ Image(systemName: "sparkle.magnifyingglass")
+ }
+ .accessibilityLabel("Discover public projects")
+ }
+ if appState.currentUser != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ isPresentingCreate = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Create project")
+ }
+ }
+ }
+ .sheet(isPresented: $isPresentingCreate) {
+ ProjectFormSheet(
+ title: "New Project",
+ confirmationTitle: "Create",
+ isSaving: viewModel.isSaving,
+ error: viewModel.saveError,
+ includeWebsite: false,
+ onSave: { await viewModel.createProject($0) }
+ )
+ }
} else {
SRHTLoadingStateView(message: "Loading projects…")
}
@@ -70,7 +124,7 @@ struct ProjectsListView: View {
List {
ForEach(viewModel.filteredProjects) { project in
NavigationLink {
- ProjectDetailView(project: project)
+ ProjectDetailView(project: project, canManage: true)
} label: {
ProjectListRow(project: project)
}
diff --git a/HutchTests/ProjectFormTests.swift b/HutchTests/ProjectFormTests.swift
new file mode 100644
index 0000000..a4b4dfa
--- /dev/null
+++ b/HutchTests/ProjectFormTests.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ProjectFormTests {
+ @Test
+ func parsesTagsSplittingAndTrimming() {
+ #expect(ProjectFormSheet.parseTags("swift, ios , ") == ["swift", "ios"])
+ #expect(ProjectFormSheet.parseTags("a,b,c") == ["a", "b", "c"])
+ }
+
+ @Test
+ func parseTagsDeduplicatesCaseInsensitively() {
+ #expect(ProjectFormSheet.parseTags("Swift, swift, SWIFT") == ["Swift"])
+ }
+
+ @Test
+ func parseTagsEmptyInputYieldsEmpty() {
+ #expect(ProjectFormSheet.parseTags(" ").isEmpty)
+ #expect(ProjectFormSheet.parseTags("").isEmpty)
+ }
+
+ @Test
+ func linkableResourceDisplayNameCombinesOwnerAndName() {
+ let resource = LinkableResource(rid: "r1", name: "hutch", ownerCanonicalName: "~alice", kind: .source)
+ #expect(resource.displayName == "~alice/hutch")
+ #expect(resource.id == "r1")
+ }
+
+ @Test
+ func discoveredProjectIDMatchesProject() {
+ let project = Project(
+ metadata: .init(id: "p1", name: "Hutch", description: nil, website: nil, visibility: .publicVisibility, tags: [], updated: .now),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
+ )
+ let discovered = DiscoveredProject(project: project, ownerCanonicalName: "~alice")
+ #expect(discovered.id == "p1")
+ }
+}