Commit 1924d6476d

1924d6476d3b2319515c6c4ffcb58a5be5a206f3

parent: 59b53afae0

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 00:58 UTC

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

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 39;
518 CURRENT_PROJECT_VERSION = 40;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.17.0;
535 MARKETING_VERSION = 2.17.1;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 39;
555 CURRENT_PROJECT_VERSION = 40;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.17.0;
572 MARKETING_VERSION = 2.17.1;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 39;
635 CURRENT_PROJECT_VERSION = 40;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.17.0;
645 MARKETING_VERSION = 2.17.1;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 39;
664 CURRENT_PROJECT_VERSION = 40;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.17.0;
674 MARKETING_VERSION = 2.17.1;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -9,6 +9,7 @@ enum AppStorageKeys {
99 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
1010 static let systemStatusIncidentCache = "systemStatusIncidentCache"
1111 static let homeProjectsExpanded = "homeProjectsExpanded"
12 static let pinnedHomeProjects = "pinnedHomeProjects"
1213 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
1314 static let homeBuildsExpanded = "homeBuildsExpanded"
1415 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
Hutch/App/RootView.swift +3
@@ -302,6 +302,7 @@ enum MoreDestination: Hashable {
302302
303303enum MoreRoute: Hashable {
304304 case lookup
305 case projects
305306 case lists
306307 case pastes
307308 case profile
@@ -320,6 +321,8 @@ private struct MoreNavigationRoot: View {
320321 switch route {
321322 case .lookup:
322323 LookupView()
324 case .projects:
325 ProjectsListView()
323326 case .lists:
324327 MailingListListView()
325328 case .pastes:
Hutch/Models/Project.swift +27
@@ -69,6 +69,33 @@ struct Project: Identifiable, Hashable, Sendable {
6969 let mailingLists: [MailingList]
7070 let sources: [SourceRepo]
7171 let trackers: [Tracker]
72 let isFullyLoaded: Bool
73
74 init(
75 id: String,
76 name: String,
77 description: String?,
78 website: String?,
79 visibility: Visibility,
80 tags: [String],
81 updated: Date,
82 mailingLists: [MailingList],
83 sources: [SourceRepo],
84 trackers: [Tracker],
85 isFullyLoaded: Bool = true
86 ) {
87 self.id = id
88 self.name = name
89 self.description = description
90 self.website = website
91 self.visibility = visibility
92 self.tags = tags
93 self.updated = updated
94 self.mailingLists = mailingLists
95 self.sources = sources
96 self.trackers = trackers
97 self.isFullyLoaded = isFullyLoaded
98 }
7299
73100 var resourceSummary: String? {
74101 let parts = [
Hutch/Networking/ProjectService.swift +28 −20
@@ -34,6 +34,7 @@ private struct ProjectDetailPayload: Decodable, Sendable {
3434 let website: String?
3535 let visibility: Visibility
3636 let tags: [String]
37 let updated: Date
3738 let mailingLists: ProjectMailingListPage
3839 let sources: ProjectSourcePage
3940 let trackers: ProjectTrackerPage
@@ -110,6 +111,7 @@ struct ProjectService: Sendable {
110111 website
111112 visibility
112113 tags
114 updated
113115 mailingLists(cursor: $mailingListsCursor) {
114116 results {
115117 rid
@@ -150,22 +152,11 @@ struct ProjectService: Sendable {
150152 }
151153
152154 func fetchProjects() async throws -> [Project] {
153 let summaries = try await fetchProjectSummaries()
154 guard !summaries.isEmpty else { return [] }
155
156 return try await withThrowingTaskGroup(of: Project.self) { group in
157 for summary in summaries {
158 group.addTask {
159 try await self.fetchProjectDetail(summary: summary)
160 }
161 }
155 try await fetchProjectSummaries().map(Self.makeSummaryProject)
156 }
162157
163 var projects: [Project] = []
164 for try await project in group {
165 projects.append(project)
166 }
167 return projects.sorted { $0.updated > $1.updated }
168 }
158 func fetchProjectDetail(rid: String) async throws -> Project {
159 try await fetchProjectDetailPayload(rid: rid)
169160 }
170161
171162 private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
@@ -192,10 +183,10 @@ struct ProjectService: Sendable {
192183 cursor = nextCursor
193184 }
194185
195 return results
186 return results.sorted { $0.updated > $1.updated }
196187 }
197188
198 private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
189 private func fetchProjectDetailPayload(rid: String) async throws -> Project {
199190 var mailingLists: [Project.MailingList] = []
200191 var sources: [Project.SourceRepo] = []
201192 var trackers: [Project.Tracker] = []
@@ -204,7 +195,7 @@ struct ProjectService: Sendable {
204195 var trackersCursor: String?
205196
206197 while true {
207 var variables: [String: any Sendable] = ["rid": summary.rid]
198 var variables: [String: any Sendable] = ["rid": rid]
208199 if let mailingListsCursor {
209200 variables["mailingListsCursor"] = mailingListsCursor
210201 }
@@ -269,15 +260,32 @@ struct ProjectService: Sendable {
269260 website: project.website,
270261 visibility: project.visibility,
271262 tags: project.tags,
272 updated: summary.updated,
263 updated: project.updated,
273264 mailingLists: deduplicate(mailingLists),
274265 sources: deduplicate(sources),
275 trackers: deduplicate(trackers)
266 trackers: deduplicate(trackers),
267 isFullyLoaded: true
276268 )
277269 }
278270 }
279271 }
280272
273 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
274 Project(
275 id: summary.rid,
276 name: summary.name,
277 description: summary.description,
278 website: summary.website,
279 visibility: summary.visibility,
280 tags: summary.tags,
281 updated: summary.updated,
282 mailingLists: [],
283 sources: [],
284 trackers: [],
285 isFullyLoaded: false
286 )
287 }
288
281289 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
282290 var seen = Set<T.ID>()
283291 return items.filter { item in
Hutch/Views/Home/HomeView.swift +6 −32
@@ -69,11 +69,11 @@ struct HomeView: View {
6969 .listStyle(.insetGrouped)
7070 .overlay {
7171 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
72 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
72 viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
7373 viewModel.unreadInboxThreads.isEmpty {
7474 SRHTLoadingStateView(message: "Loading Home…")
7575 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
76 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
76 viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
7777 viewModel.unreadInboxThreads.isEmpty &&
7878 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
7979 ContentUnavailableView(
@@ -179,17 +179,17 @@ struct HomeView: View {
179179
180180 @ViewBuilder
181181 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
182 if !viewModel.projects.isEmpty {
183 HomeSectionView("Projects", isExpanded: $projectsExpanded) {
182 if !viewModel.pinnedProjects.isEmpty {
183 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
184184 NavigationLink {
185 HomeProjectsListView(viewModel: viewModel)
185 ProjectsListView()
186186 } label: {
187187 Text("See All")
188188 .font(.caption.weight(.medium))
189189 }
190190 .buttonStyle(.plain)
191191 } content: {
192 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
192 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
193193 NavigationLink {
194194 ProjectDetailView(project: project)
195195 } label: {
@@ -411,32 +411,6 @@ private struct HomeProjectRow: View {
411411 }
412412}
413413
414private struct HomeProjectsListView: View {
415 let viewModel: HomeViewModel
416
417 var body: some View {
418 List {
419 ForEach(viewModel.projects) { project in
420 NavigationLink {
421 ProjectDetailView(project: project)
422 } label: {
423 HomeProjectRow(project: project)
424 }
425 }
426 }
427 .navigationTitle("Projects")
428 .navigationBarTitleDisplayMode(.inline)
429 .refreshable {
430 await viewModel.loadDashboard()
431 }
432 .overlay {
433 if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
434 SRHTLoadingStateView(message: "Loading projects…")
435 }
436 }
437 }
438}
439
440414private struct HomeBuildRow: View {
441415 @Environment(AppState.self) private var appState
442416 let build: HomeBuildItem
Hutch/Views/Home/HomeViewModel.swift +13 −1
@@ -195,6 +195,10 @@ final class HomeViewModel {
195195 private let ticketFetchConcurrencyLimit = 6
196196 private let inboxUnreadConcurrencyLimit = 4
197197
198 private var currentUserKey: String {
199 currentUser.canonicalName
200 }
201
198202 private static let jobsQuery = """
199203 query jobs {
200204 jobs {
@@ -392,7 +396,15 @@ final class HomeViewModel {
392396 }
393397
394398 var hasDashboardContent: Bool {
395 !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
399 !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
400 }
401
402 var pinnedProjects: [Project] {
403 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey)
404 guard !pinnedIDs.isEmpty else { return [] }
405
406 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
407 return pinnedIDs.compactMap { projectsByID[$0] }
396408 }
397409
398410 var failedBuildCount: Int {
Hutch/Views/Lookup/LookupView.swift +2
@@ -422,6 +422,8 @@ struct LookupView: View {
422422 switch route {
423423 case .lookup:
424424 LookupView()
425 case .projects:
426 ProjectsListView()
425427 case .lists:
426428 MailingListListView()
427429 case .pastes:
Hutch/Views/More/MoreView.swift +4
@@ -19,6 +19,10 @@ struct MoreView: View {
1919 }
2020
2121 Section("Other Services") {
22 NavigationLink(value: MoreRoute.projects) {
23 Label("Projects", systemImage: "square.stack.3d.up")
24 }
25
2226 NavigationLink(value: MoreRoute.lists) {
2327 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
2428 }
Hutch/Views/Projects/ProjectDetailView.swift +151 −14
@@ -2,49 +2,124 @@ import SwiftUI
22
33struct ProjectDetailView: View {
44 let project: Project
5
56 @Environment(AppState.self) private var appState
67 @Environment(\.dismiss) private var dismiss
8 @State private var detailProject: Project?
9 @State private var isLoading = false
10 @State private var error: String?
11 @State private var pinChangeCount = 0
12
13 private var displayedProject: Project {
14 detailProject ?? project
15 }
16
17 private var currentUserKey: String? {
18 appState.currentUser?.canonicalName
19 }
20
21 private var isPinnedToHome: Bool {
22 _ = pinChangeCount
23 guard let currentUserKey else { return false }
24 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey)
25 }
726
827 var body: some View {
928 List {
1029 headerSection
30 linksSection
1131 repositoriesSection
1232 trackersSection
1333 mailingListsSection
1434 }
15 .navigationTitle(project.name)
35 .themedList()
36 .navigationTitle(displayedProject.name)
1637 .navigationBarTitleDisplayMode(.inline)
38 .overlay {
39 if isLoading, detailProject == nil, !project.isFullyLoaded {
40 SRHTLoadingStateView(message: "Loading project…")
41 } else if let error, detailProject == nil, !project.isFullyLoaded {
42 SRHTErrorStateView(
43 title: "Couldn't Load Project",
44 message: error,
45 retryAction: { await loadProjectIfNeeded(forceRefresh: true) }
46 )
47 }
48 }
49 .toolbar {
50 if currentUserKey != nil {
51 ToolbarItem(placement: .topBarTrailing) {
52 Button {
53 togglePinnedState()
54 } label: {
55 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
56 }
57 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
58 }
59 }
60 }
61 .task {
62 await loadProjectIfNeeded()
63 }
64 .refreshable {
65 await loadProjectIfNeeded(forceRefresh: true)
66 }
67 .srhtErrorBanner(error: $error)
1768 }
1869
1970 @ViewBuilder
2071 private var headerSection: some View {
2172 Section {
22 VStack(alignment: .leading, spacing: 8) {
23 Text(project.name)
73 VStack(alignment: .leading, spacing: 10) {
74 Text(displayedProject.name)
2475 .font(.headline)
2576
26 if let description = project.description, !description.isEmpty {
77 if let description = displayedProject.description, !description.isEmpty {
2778 Text(description)
2879 .font(.subheadline)
2980 .foregroundStyle(.secondary)
3081 }
3182
32 if let website = project.website, let url = URL(string: website) {
33 Link(destination: url) {
34 Label(website, systemImage: "link")
35 .font(.subheadline)
83 if !displayedProject.tags.isEmpty {
84 ScrollView(.horizontal, showsIndicators: false) {
85 HStack(spacing: 8) {
86 ForEach(displayedProject.tags, id: \.self) { tag in
87 Text(tag)
88 .font(.caption.weight(.medium))
89 .padding(.horizontal, 10)
90 .padding(.vertical, 4)
91 .background(.quaternary, in: Capsule())
92 }
93 }
3694 }
3795 }
96
97 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
3898 }
3999 .padding(.vertical, 4)
40100 }
41101 }
42102
103 @ViewBuilder
104 private var linksSection: some View {
105 let links = projectLinks(for: displayedProject)
106 if !links.isEmpty {
107 Section("Links") {
108 ForEach(links) { link in
109 Link(destination: link.url) {
110 Label(link.title, systemImage: link.systemImage)
111 .font(.subheadline)
112 }
113 }
114 }
115 }
116 }
117
43118 @ViewBuilder
44119 private var repositoriesSection: some View {
45 if !project.sources.isEmpty {
120 if !displayedProject.sources.isEmpty {
46121 Section("Repositories") {
47 ForEach(project.sources) { source in
122 ForEach(displayedProject.sources) { source in
48123 Button {
49124 Task {
50125 try? await appState.openProjectSource(source)
@@ -65,9 +140,9 @@ struct ProjectDetailView: View {
65140
66141 @ViewBuilder
67142 private var trackersSection: some View {
68 if !project.trackers.isEmpty {
143 if !displayedProject.trackers.isEmpty {
69144 Section("Trackers") {
70 ForEach(project.trackers) { tracker in
145 ForEach(displayedProject.trackers) { tracker in
71146 Button {
72147 Task {
73148 try? await appState.openProjectTracker(tracker)
@@ -88,9 +163,9 @@ struct ProjectDetailView: View {
88163
89164 @ViewBuilder
90165 private var mailingListsSection: some View {
91 if !project.mailingLists.isEmpty {
166 if !displayedProject.mailingLists.isEmpty {
92167 Section("Mailing Lists") {
93 ForEach(project.mailingLists) { mailingList in
168 ForEach(displayedProject.mailingLists) { mailingList in
94169 Button {
95170 appState.openMailingList(mailingList.inboxReference)
96171 dismiss()
@@ -106,6 +181,68 @@ struct ProjectDetailView: View {
106181 }
107182 }
108183 }
184
185 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
186 guard forceRefresh || !project.isFullyLoaded else {
187 detailProject = project
188 return
189 }
190 guard !isLoading else { return }
191
192 isLoading = true
193 error = nil
194 defer { isLoading = false }
195
196 do {
197 let service = ProjectService(client: appState.client)
198 detailProject = try await service.fetchProjectDetail(rid: project.id)
199 } catch {
200 self.error = "Failed to load project"
201 }
202 }
203
204 private func togglePinnedState() {
205 guard let currentUserKey else { return }
206 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey)
207 pinChangeCount += 1
208 }
209
210 private func projectLinks(for project: Project) -> [ProjectLink] {
211 var links: [ProjectLink] = []
212
213 if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines),
214 let url = URL(string: website),
215 !website.isEmpty {
216 links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url))
217 }
218
219 if let source = project.sources.first,
220 let url = sourceURL(for: source) {
221 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url))
222 }
223
224 if let tracker = project.trackers.first,
225 let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) {
226 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url))
227 }
228
229 return links
230 }
231
232 private func sourceURL(for source: Project.SourceRepo) -> URL? {
233 var components = URLComponents()
234 components.scheme = "https"
235 components.host = "\(source.repoType.service.rawValue).sr.ht"
236 components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)"
237 return components.url
238 }
239}
240
241private struct ProjectLink: Identifiable {
242 let id: String
243 let title: String
244 let systemImage: String
245 let url: URL
109246}
110247
111248private struct ProjectResourceRow: View {
Hutch/Views/Projects/ProjectPinStore.swift added +50
@@ -0,0 +1,50 @@
1import Foundation
2
3enum ProjectPinStore {
4 static func loadPinnedProjectIDs(
5 for userKey: String,
6 defaults: UserDefaults = .standard
7 ) -> [String] {
8 let pinnedProjects = loadAll(defaults: defaults)
9 return pinnedProjects[userKey] ?? []
10 }
11
12 static func isPinned(
13 projectID: String,
14 for userKey: String,
15 defaults: UserDefaults = .standard
16 ) -> Bool {
17 loadPinnedProjectIDs(for: userKey, defaults: defaults).contains(projectID)
18 }
19
20 static func togglePin(
21 projectID: String,
22 for userKey: String,
23 defaults: UserDefaults = .standard
24 ) {
25 var pinnedProjects = loadAll(defaults: defaults)
26 var projectIDs = pinnedProjects[userKey] ?? []
27
28 if let index = projectIDs.firstIndex(of: projectID) {
29 projectIDs.remove(at: index)
30 } else {
31 projectIDs.append(projectID)
32 }
33
34 pinnedProjects[userKey] = projectIDs
35 save(pinnedProjects, defaults: defaults)
36 }
37
38 private static func loadAll(defaults: UserDefaults) -> [String: [String]] {
39 guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else {
40 return [:]
41 }
42
43 return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
44 }
45
46 private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
47 guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
48 defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
49 }
50}
Hutch/Views/Projects/ProjectsListView.swift added +146
@@ -0,0 +1,146 @@
1import SwiftUI
2
3@Observable
4@MainActor
5final class ProjectsListViewModel {
6 private(set) var projects: [Project] = []
7 private(set) var isLoading = false
8 var error: String?
9 var searchText = ""
10
11 private let service: ProjectService
12
13 init(service: ProjectService) {
14 self.service = service
15 }
16
17 var filteredProjects: [Project] {
18 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
19 guard !query.isEmpty else { return projects }
20
21 return projects.filter {
22 $0.name.lowercased().contains(query) ||
23 ($0.description?.lowercased().contains(query) ?? false) ||
24 $0.tags.contains(where: { $0.lowercased().contains(query) })
25 }
26 }
27
28 func loadProjects() async {
29 guard !isLoading else { return }
30 isLoading = true
31 error = nil
32 defer { isLoading = false }
33
34 do {
35 projects = try await service.fetchProjects()
36 } catch {
37 self.error = "Failed to load projects"
38 }
39 }
40}
41
42struct ProjectsListView: View {
43 @Environment(AppState.self) private var appState
44 @State private var viewModel: ProjectsListViewModel?
45
46 var body: some View {
47 Group {
48 if let viewModel {
49 content(viewModel)
50 } else {
51 SRHTLoadingStateView(message: "Loading projects…")
52 }
53 }
54 .navigationTitle("Projects")
55 .task {
56 if viewModel == nil {
57 let vm = ProjectsListViewModel(service: ProjectService(client: appState.client))
58 viewModel = vm
59 await vm.loadProjects()
60 }
61 }
62 }
63
64 @ViewBuilder
65 private func content(_ viewModel: ProjectsListViewModel) -> some View {
66 @Bindable var vm = viewModel
67
68 List {
69 ForEach(viewModel.filteredProjects) { project in
70 NavigationLink {
71 ProjectDetailView(project: project)
72 } label: {
73 ProjectListRow(project: project)
74 }
75 }
76 }
77 .themedList()
78 .listStyle(.plain)
79 .searchable(
80 text: $vm.searchText,
81 placement: .navigationBarDrawer(displayMode: .always),
82 prompt: "Search projects"
83 )
84 .overlay {
85 if viewModel.isLoading, viewModel.projects.isEmpty {
86 SRHTLoadingStateView(message: "Loading projects…")
87 } else if let error = viewModel.error, viewModel.projects.isEmpty {
88 SRHTErrorStateView(
89 title: "Couldn't Load Projects",
90 message: error,
91 retryAction: { await viewModel.loadProjects() }
92 )
93 } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty {
94 ContentUnavailableView.search(text: viewModel.searchText)
95 } else if viewModel.projects.isEmpty {
96 ContentUnavailableView(
97 "No Projects",
98 systemImage: "square.stack.3d.up",
99 description: Text("Your SourceHut projects will appear here.")
100 )
101 }
102 }
103 .srhtErrorBanner(error: $vm.error)
104 .refreshable {
105 await viewModel.loadProjects()
106 }
107 .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) {
108 await viewModel.loadProjects()
109 }
110 }
111}
112
113private struct ProjectListRow: View {
114 let project: Project
115
116 var body: some View {
117 VStack(alignment: .leading, spacing: 4) {
118 HStack(alignment: .firstTextBaseline, spacing: 8) {
119 Text(project.name)
120 .font(.subheadline.weight(.medium))
121 .lineLimit(1)
122
123 Spacer()
124
125 Text(project.updated.relativeDescription)
126 .font(.caption)
127 .foregroundStyle(.tertiary)
128 }
129
130 if let description = project.description, !description.isEmpty {
131 Text(description)
132 .font(.caption)
133 .foregroundStyle(.secondary)
134 .lineLimit(2)
135 }
136
137 if let summary = project.resourceSummary {
138 Text(summary)
139 .font(.caption)
140 .foregroundStyle(.tertiary)
141 .lineLimit(1)
142 }
143 }
144 .padding(.vertical, 2)
145 }
146}
HutchTests/ProjectPinStoreTests.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct ProjectPinStoreTests {
6 @Test
7 func storesPinnedProjectsPerUser() {
8 let defaults = UserDefaults(suiteName: #function)!
9 defaults.removePersistentDomain(forName: #function)
10
11 ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
12 ProjectPinStore.togglePin(projectID: "project-2", for: "~bob", defaults: defaults)
13
14 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1"])
15 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~bob", defaults: defaults) == ["project-2"])
16 }
17
18 @Test
19 func toggleRemovesExistingPin() {
20 let defaults = UserDefaults(suiteName: #function)!
21 defaults.removePersistentDomain(forName: #function)
22
23 ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
24 ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults)
25
26 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
27 }
28}