krz/hutch

an ios client for sourcehut

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

1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T09:12:50Z

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).
 Hutch/Networking/ProjectService.swift              | 469 +++++++++++++++++++++
 Hutch/Views/Projects/DiscoverProjectsView.swift    | 166 ++++++++
 .../Projects/ManageProjectResourcesView.swift      | 303 +++++++++++++
 Hutch/Views/Projects/ProjectDetailView.swift       |  90 ++++
 Hutch/Views/Projects/ProjectFormSheet.swift        | 144 +++++++
 Hutch/Views/Projects/ProjectsListView.swift        |  58 ++-
 HutchTests/ProjectFormTests.swift                  |  39 ++
 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")
+    }
+}