summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/App/RootView.swift3
-rw-r--r--Hutch/Models/Project.swift27
-rw-r--r--Hutch/Networking/ProjectService.swift48
-rw-r--r--Hutch/Views/Home/HomeView.swift38
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift14
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/More/MoreView.swift4
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift165
-rw-r--r--Hutch/Views/Projects/ProjectPinStore.swift50
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift146
-rw-r--r--HutchTests/ProjectPinStoreTests.swift28
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)
+ }
+}