krz/hutch

an ios client for sourcehut

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

9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T00:58:20Z

feat: add projects list, detail view, and home pinning

Implements: https://todo.sr.ht/~ccleberg/hutch/26
Implements: https://todo.sr.ht/~ccleberg/hutch/27
Implements: https://todo.sr.ht/~ccleberg/hutch/28
 Hutch.xcodeproj/project.pbxproj              |  16 +--
 Hutch/App/AppStorageKeys.swift               |   1 +
 Hutch/App/RootView.swift                     |   3 +
 Hutch/Models/Project.swift                   |  27 +++++
 Hutch/Networking/ProjectService.swift        |  48 ++++----
 Hutch/Views/Home/HomeView.swift              |  38 +-----
 Hutch/Views/Home/HomeViewModel.swift         |  14 ++-
 Hutch/Views/Lookup/LookupView.swift          |   2 +
 Hutch/Views/More/MoreView.swift              |   4 +
 Hutch/Views/Projects/ProjectDetailView.swift | 165 ++++++++++++++++++++++++---
 Hutch/Views/Projects/ProjectPinStore.swift   |  50 ++++++++
 Hutch/Views/Projects/ProjectsListView.swift  | 146 ++++++++++++++++++++++++
 HutchTests/ProjectPinStoreTests.swift        |  28 +++++
 13 files changed, 467 insertions(+), 75 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 4328148..b457ff9 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 39;
+				CURRENT_PROJECT_VERSION = 40;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.17.0;
+				MARKETING_VERSION = 2.17.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 39;
+				CURRENT_PROJECT_VERSION = 40;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.17.0;
+				MARKETING_VERSION = 2.17.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 39;
+				CURRENT_PROJECT_VERSION = 40;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.17.0;
+				MARKETING_VERSION = 2.17.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 39;
+				CURRENT_PROJECT_VERSION = 40;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.17.0;
+				MARKETING_VERSION = 2.17.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index eaf54f0..9a97657 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -9,6 +9,7 @@ enum AppStorageKeys {
     static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
     static let systemStatusIncidentCache = "systemStatusIncidentCache"
     static let homeProjectsExpanded = "homeProjectsExpanded"
+    static let pinnedHomeProjects = "pinnedHomeProjects"
     static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
     static let homeBuildsExpanded = "homeBuildsExpanded"
     static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index baca7a7..9e91a71 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -302,6 +302,7 @@ enum MoreDestination: Hashable {
 
 enum MoreRoute: Hashable {
     case lookup
+    case projects
     case lists
     case pastes
     case profile
@@ -320,6 +321,8 @@ private struct MoreNavigationRoot: View {
                 switch route {
                 case .lookup:
                     LookupView()
+                case .projects:
+                    ProjectsListView()
                 case .lists:
                     MailingListListView()
                 case .pastes:
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 2cd8e00..54c0318 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -69,6 +69,33 @@ struct Project: Identifiable, Hashable, Sendable {
     let mailingLists: [MailingList]
     let sources: [SourceRepo]
     let trackers: [Tracker]
+    let isFullyLoaded: Bool
+
+    init(
+        id: String,
+        name: String,
+        description: String?,
+        website: String?,
+        visibility: Visibility,
+        tags: [String],
+        updated: Date,
+        mailingLists: [MailingList],
+        sources: [SourceRepo],
+        trackers: [Tracker],
+        isFullyLoaded: Bool = true
+    ) {
+        self.id = id
+        self.name = name
+        self.description = description
+        self.website = website
+        self.visibility = visibility
+        self.tags = tags
+        self.updated = updated
+        self.mailingLists = mailingLists
+        self.sources = sources
+        self.trackers = trackers
+        self.isFullyLoaded = isFullyLoaded
+    }
 
     var resourceSummary: String? {
         let parts = [
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index a76d1d7..5a0b466 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -34,6 +34,7 @@ private struct ProjectDetailPayload: Decodable, Sendable {
     let website: String?
     let visibility: Visibility
     let tags: [String]
+    let updated: Date
     let mailingLists: ProjectMailingListPage
     let sources: ProjectSourcePage
     let trackers: ProjectTrackerPage
@@ -110,6 +111,7 @@ struct ProjectService: Sendable {
             website
             visibility
             tags
+            updated
             mailingLists(cursor: $mailingListsCursor) {
                 results {
                     rid
@@ -150,22 +152,11 @@ struct ProjectService: Sendable {
     }
 
     func fetchProjects() async throws -> [Project] {
-        let summaries = try await fetchProjectSummaries()
-        guard !summaries.isEmpty else { return [] }
-
-        return try await withThrowingTaskGroup(of: Project.self) { group in
-            for summary in summaries {
-                group.addTask {
-                    try await self.fetchProjectDetail(summary: summary)
-                }
-            }
+        try await fetchProjectSummaries().map(Self.makeSummaryProject)
+    }
 
-            var projects: [Project] = []
-            for try await project in group {
-                projects.append(project)
-            }
-            return projects.sorted { $0.updated > $1.updated }
-        }
+    func fetchProjectDetail(rid: String) async throws -> Project {
+        try await fetchProjectDetailPayload(rid: rid)
     }
 
     private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
@@ -192,10 +183,10 @@ struct ProjectService: Sendable {
             cursor = nextCursor
         }
 
-        return results
+        return results.sorted { $0.updated > $1.updated }
     }
 
-    private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
+    private func fetchProjectDetailPayload(rid: String) async throws -> Project {
         var mailingLists: [Project.MailingList] = []
         var sources: [Project.SourceRepo] = []
         var trackers: [Project.Tracker] = []
@@ -204,7 +195,7 @@ struct ProjectService: Sendable {
         var trackersCursor: String?
 
         while true {
-            var variables: [String: any Sendable] = ["rid": summary.rid]
+            var variables: [String: any Sendable] = ["rid": rid]
             if let mailingListsCursor {
                 variables["mailingListsCursor"] = mailingListsCursor
             }
@@ -269,15 +260,32 @@ struct ProjectService: Sendable {
                     website: project.website,
                     visibility: project.visibility,
                     tags: project.tags,
-                    updated: summary.updated,
+                    updated: project.updated,
                     mailingLists: deduplicate(mailingLists),
                     sources: deduplicate(sources),
-                    trackers: deduplicate(trackers)
+                    trackers: deduplicate(trackers),
+                    isFullyLoaded: true
                 )
             }
         }
     }
 
+    private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
+        Project(
+            id: summary.rid,
+            name: summary.name,
+            description: summary.description,
+            website: summary.website,
+            visibility: summary.visibility,
+            tags: summary.tags,
+            updated: summary.updated,
+            mailingLists: [],
+            sources: [],
+            trackers: [],
+            isFullyLoaded: false
+        )
+    }
+
     private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
         var seen = Set<T.ID>()
         return items.filter { item in
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index bbae296..da58587 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -69,11 +69,11 @@ struct HomeView: View {
         .listStyle(.insetGrouped)
         .overlay {
             if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
-                viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+                viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
                 viewModel.unreadInboxThreads.isEmpty {
                 SRHTLoadingStateView(message: "Loading Home…")
             } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
-                        viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+                        viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
                         viewModel.unreadInboxThreads.isEmpty &&
                         viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
                 ContentUnavailableView(
@@ -179,17 +179,17 @@ struct HomeView: View {
 
     @ViewBuilder
     private func projectsSection(_ viewModel: HomeViewModel) -> some View {
-        if !viewModel.projects.isEmpty {
-            HomeSectionView("Projects", isExpanded: $projectsExpanded) {
+        if !viewModel.pinnedProjects.isEmpty {
+            HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
                 NavigationLink {
-                    HomeProjectsListView(viewModel: viewModel)
+                    ProjectsListView()
                 } label: {
                     Text("See All")
                         .font(.caption.weight(.medium))
                 }
                 .buttonStyle(.plain)
             } content: {
-                ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
+                ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
                     NavigationLink {
                         ProjectDetailView(project: project)
                     } label: {
@@ -411,32 +411,6 @@ private struct HomeProjectRow: View {
     }
 }
 
-private struct HomeProjectsListView: View {
-    let viewModel: HomeViewModel
-
-    var body: some View {
-        List {
-            ForEach(viewModel.projects) { project in
-                NavigationLink {
-                    ProjectDetailView(project: project)
-                } label: {
-                    HomeProjectRow(project: project)
-                }
-            }
-        }
-        .navigationTitle("Projects")
-        .navigationBarTitleDisplayMode(.inline)
-        .refreshable {
-            await viewModel.loadDashboard()
-        }
-        .overlay {
-            if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
-                SRHTLoadingStateView(message: "Loading projects…")
-            }
-        }
-    }
-}
-
 private struct HomeBuildRow: View {
     @Environment(AppState.self) private var appState
     let build: HomeBuildItem
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ec2b324..e9f0f50 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -195,6 +195,10 @@ final class HomeViewModel {
     private let ticketFetchConcurrencyLimit = 6
     private let inboxUnreadConcurrencyLimit = 4
 
+    private var currentUserKey: String {
+        currentUser.canonicalName
+    }
+
     private static let jobsQuery = """
     query jobs {
         jobs {
@@ -392,7 +396,15 @@ final class HomeViewModel {
     }
 
     var hasDashboardContent: Bool {
-        !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
+        !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
+    }
+
+    var pinnedProjects: [Project] {
+        let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey)
+        guard !pinnedIDs.isEmpty else { return [] }
+
+        let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
+        return pinnedIDs.compactMap { projectsByID[$0] }
     }
 
     var failedBuildCount: Int {
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index bfb532f..6cefcf1 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -422,6 +422,8 @@ struct LookupView: View {
                 switch route {
                 case .lookup:
                     LookupView()
+                case .projects:
+                    ProjectsListView()
                 case .lists:
                     MailingListListView()
                 case .pastes:
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index caec3d5..8be25ca 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -19,6 +19,10 @@ struct MoreView: View {
             }
 
             Section("Other Services") {
+                NavigationLink(value: MoreRoute.projects) {
+                    Label("Projects", systemImage: "square.stack.3d.up")
+                }
+
                 NavigationLink(value: MoreRoute.lists) {
                     Label("Mailing Lists", systemImage: "list.bullet.rectangle")
                 }
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index 89601a9..a04c526 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -2,49 +2,124 @@ import SwiftUI
 
 struct ProjectDetailView: View {
     let project: Project
+
     @Environment(AppState.self) private var appState
     @Environment(\.dismiss) private var dismiss
+    @State private var detailProject: Project?
+    @State private var isLoading = false
+    @State private var error: String?
+    @State private var pinChangeCount = 0
+
+    private var displayedProject: Project {
+        detailProject ?? project
+    }
+
+    private var currentUserKey: String? {
+        appState.currentUser?.canonicalName
+    }
+
+    private var isPinnedToHome: Bool {
+        _ = pinChangeCount
+        guard let currentUserKey else { return false }
+        return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey)
+    }
 
     var body: some View {
         List {
             headerSection
+            linksSection
             repositoriesSection
             trackersSection
             mailingListsSection
         }
-        .navigationTitle(project.name)
+        .themedList()
+        .navigationTitle(displayedProject.name)
         .navigationBarTitleDisplayMode(.inline)
+        .overlay {
+            if isLoading, detailProject == nil, !project.isFullyLoaded {
+                SRHTLoadingStateView(message: "Loading project…")
+            } else if let error, detailProject == nil, !project.isFullyLoaded {
+                SRHTErrorStateView(
+                    title: "Couldn't Load Project",
+                    message: error,
+                    retryAction: { await loadProjectIfNeeded(forceRefresh: true) }
+                )
+            }
+        }
+        .toolbar {
+            if currentUserKey != nil {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        togglePinnedState()
+                    } label: {
+                        Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
+                    }
+                    .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
+                }
+            }
+        }
+        .task {
+            await loadProjectIfNeeded()
+        }
+        .refreshable {
+            await loadProjectIfNeeded(forceRefresh: true)
+        }
+        .srhtErrorBanner(error: $error)
     }
 
     @ViewBuilder
     private var headerSection: some View {
         Section {
-            VStack(alignment: .leading, spacing: 8) {
-                Text(project.name)
+            VStack(alignment: .leading, spacing: 10) {
+                Text(displayedProject.name)
                     .font(.headline)
 
-                if let description = project.description, !description.isEmpty {
+                if let description = displayedProject.description, !description.isEmpty {
                     Text(description)
                         .font(.subheadline)
                         .foregroundStyle(.secondary)
                 }
 
-                if let website = project.website, let url = URL(string: website) {
-                    Link(destination: url) {
-                        Label(website, systemImage: "link")
-                            .font(.subheadline)
+                if !displayedProject.tags.isEmpty {
+                    ScrollView(.horizontal, showsIndicators: false) {
+                        HStack(spacing: 8) {
+                            ForEach(displayedProject.tags, id: \.self) { tag in
+                                Text(tag)
+                                    .font(.caption.weight(.medium))
+                                    .padding(.horizontal, 10)
+                                    .padding(.vertical, 4)
+                                    .background(.quaternary, in: Capsule())
+                            }
+                        }
                     }
                 }
+
+                LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
             }
             .padding(.vertical, 4)
         }
     }
 
+    @ViewBuilder
+    private var linksSection: some View {
+        let links = projectLinks(for: displayedProject)
+        if !links.isEmpty {
+            Section("Links") {
+                ForEach(links) { link in
+                    Link(destination: link.url) {
+                        Label(link.title, systemImage: link.systemImage)
+                            .font(.subheadline)
+                    }
+                }
+            }
+        }
+    }
+
     @ViewBuilder
     private var repositoriesSection: some View {
-        if !project.sources.isEmpty {
+        if !displayedProject.sources.isEmpty {
             Section("Repositories") {
-                ForEach(project.sources) { source in
+                ForEach(displayedProject.sources) { source in
                     Button {
                         Task {
                             try? await appState.openProjectSource(source)
@@ -65,9 +140,9 @@ struct ProjectDetailView: View {
 
     @ViewBuilder
     private var trackersSection: some View {
-        if !project.trackers.isEmpty {
+        if !displayedProject.trackers.isEmpty {
             Section("Trackers") {
-                ForEach(project.trackers) { tracker in
+                ForEach(displayedProject.trackers) { tracker in
                     Button {
                         Task {
                             try? await appState.openProjectTracker(tracker)
@@ -88,9 +163,9 @@ struct ProjectDetailView: View {
 
     @ViewBuilder
     private var mailingListsSection: some View {
-        if !project.mailingLists.isEmpty {
+        if !displayedProject.mailingLists.isEmpty {
             Section("Mailing Lists") {
-                ForEach(project.mailingLists) { mailingList in
+                ForEach(displayedProject.mailingLists) { mailingList in
                     Button {
                         appState.openMailingList(mailingList.inboxReference)
                         dismiss()
@@ -106,6 +181,68 @@ struct ProjectDetailView: View {
             }
         }
     }
+
+    private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
+        guard forceRefresh || !project.isFullyLoaded else {
+            detailProject = project
+            return
+        }
+        guard !isLoading else { return }
+
+        isLoading = true
+        error = nil
+        defer { isLoading = false }
+
+        do {
+            let service = ProjectService(client: appState.client)
+            detailProject = try await service.fetchProjectDetail(rid: project.id)
+        } catch {
+            self.error = "Failed to load project"
+        }
+    }
+
+    private func togglePinnedState() {
+        guard let currentUserKey else { return }
+        ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey)
+        pinChangeCount += 1
+    }
+
+    private func projectLinks(for project: Project) -> [ProjectLink] {
+        var links: [ProjectLink] = []
+
+        if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines),
+           let url = URL(string: website),
+           !website.isEmpty {
+            links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url))
+        }
+
+        if let source = project.sources.first,
+           let url = sourceURL(for: source) {
+            links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url))
+        }
+
+        if let tracker = project.trackers.first,
+           let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) {
+            links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url))
+        }
+
+        return links
+    }
+
+    private func sourceURL(for source: Project.SourceRepo) -> URL? {
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = "\(source.repoType.service.rawValue).sr.ht"
+        components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)"
+        return components.url
+    }
+}
+
+private struct ProjectLink: Identifiable {
+    let id: String
+    let title: String
+    let systemImage: String
+    let url: URL
 }
 
 private struct ProjectResourceRow: View {
diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift
new file mode 100644
index 0000000..cd3d961
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectPinStore.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+enum ProjectPinStore {
+    static func loadPinnedProjectIDs(
+        for userKey: String,
+        defaults: UserDefaults = .standard
+    ) -> [String] {
+        let pinnedProjects = loadAll(defaults: defaults)
+        return pinnedProjects[userKey] ?? []
+    }
+
+    static func isPinned(
+        projectID: String,
+        for userKey: String,
+        defaults: UserDefaults = .standard
+    ) -> Bool {
+        loadPinnedProjectIDs(for: userKey, defaults: defaults).contains(projectID)
+    }
+
+    static func togglePin(
+        projectID: String,
+        for userKey: String,
+        defaults: UserDefaults = .standard
+    ) {
+        var pinnedProjects = loadAll(defaults: defaults)
+        var projectIDs = pinnedProjects[userKey] ?? []
+
+        if let index = projectIDs.firstIndex(of: projectID) {
+            projectIDs.remove(at: index)
+        } else {
+            projectIDs.append(projectID)
+        }
+
+        pinnedProjects[userKey] = projectIDs
+        save(pinnedProjects, defaults: defaults)
+    }
+
+    private static func loadAll(defaults: UserDefaults) -> [String: [String]] {
+        guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else {
+            return [:]
+        }
+
+        return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+    }
+
+    private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
+        guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
+        defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
+    }
+}
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
new file mode 100644
index 0000000..0ffe183
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -0,0 +1,146 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class ProjectsListViewModel {
+    private(set) var projects: [Project] = []
+    private(set) var isLoading = false
+    var error: String?
+    var searchText = ""
+
+    private let service: ProjectService
+
+    init(service: ProjectService) {
+        self.service = service
+    }
+
+    var filteredProjects: [Project] {
+        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !query.isEmpty else { return projects }
+
+        return projects.filter {
+            $0.name.lowercased().contains(query) ||
+            ($0.description?.lowercased().contains(query) ?? false) ||
+            $0.tags.contains(where: { $0.lowercased().contains(query) })
+        }
+    }
+
+    func loadProjects() async {
+        guard !isLoading else { return }
+        isLoading = true
+        error = nil
+        defer { isLoading = false }
+
+        do {
+            projects = try await service.fetchProjects()
+        } catch {
+            self.error = "Failed to load projects"
+        }
+    }
+}
+
+struct ProjectsListView: View {
+    @Environment(AppState.self) private var appState
+    @State private var viewModel: ProjectsListViewModel?
+
+    var body: some View {
+        Group {
+            if let viewModel {
+                content(viewModel)
+            } else {
+                SRHTLoadingStateView(message: "Loading projects…")
+            }
+        }
+        .navigationTitle("Projects")
+        .task {
+            if viewModel == nil {
+                let vm = ProjectsListViewModel(service: ProjectService(client: appState.client))
+                viewModel = vm
+                await vm.loadProjects()
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func content(_ viewModel: ProjectsListViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        List {
+            ForEach(viewModel.filteredProjects) { project in
+                NavigationLink {
+                    ProjectDetailView(project: project)
+                } label: {
+                    ProjectListRow(project: project)
+                }
+            }
+        }
+        .themedList()
+        .listStyle(.plain)
+        .searchable(
+            text: $vm.searchText,
+            placement: .navigationBarDrawer(displayMode: .always),
+            prompt: "Search projects"
+        )
+        .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.loadProjects() }
+                )
+            } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
+                ContentUnavailableView.search(text: viewModel.searchText)
+            } else if viewModel.projects.isEmpty {
+                ContentUnavailableView(
+                    "No Projects",
+                    systemImage: "square.stack.3d.up",
+                    description: Text("Your SourceHut projects will appear here.")
+                )
+            }
+        }
+        .srhtErrorBanner(error: $vm.error)
+        .refreshable {
+            await viewModel.loadProjects()
+        }
+        .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
+            await viewModel.loadProjects()
+        }
+    }
+}
+
+private struct ProjectListRow: View {
+    let project: Project
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            HStack(alignment: .firstTextBaseline, spacing: 8) {
+                Text(project.name)
+                    .font(.subheadline.weight(.medium))
+                    .lineLimit(1)
+
+                Spacer()
+
+                Text(project.updated.relativeDescription)
+                    .font(.caption)
+                    .foregroundStyle(.tertiary)
+            }
+
+            if let description = project.description, !description.isEmpty {
+                Text(description)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                    .lineLimit(2)
+            }
+
+            if let summary = project.resourceSummary {
+                Text(summary)
+                    .font(.caption)
+                    .foregroundStyle(.tertiary)
+                    .lineLimit(1)
+            }
+        }
+        .padding(.vertical, 2)
+    }
+}
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
new file mode 100644
index 0000000..a4ee0f7
--- /dev/null
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -0,0 +1,28 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ProjectPinStoreTests {
+    @Test
+    func storesPinnedProjectsPerUser() {
+        let defaults = UserDefaults(suiteName: #function)!
+        defaults.removePersistentDomain(forName: #function)
+
+        ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
+        ProjectPinStore.togglePin(projectID: "project-2", for: "~bob", defaults: defaults)
+
+        #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1"])
+        #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~bob", defaults: defaults) == ["project-2"])
+    }
+
+    @Test
+    func toggleRemovesExistingPin() {
+        let defaults = UserDefaults(suiteName: #function)!
+        defaults.removePersistentDomain(forName: #function)
+
+        ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
+        ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
+
+        #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
+    }
+}