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 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 39; 518 CURRENT_PROJECT_VERSION = 40;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.17.0; 535 MARKETING_VERSION = 2.17.1;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 39; 555 CURRENT_PROJECT_VERSION = 40;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.17.0; 572 MARKETING_VERSION = 2.17.1;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 39; 635 CURRENT_PROJECT_VERSION = 40;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.17.0; 645 MARKETING_VERSION = 2.17.1;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 39; 664 CURRENT_PROJECT_VERSION = 40;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.17.0; 674 MARKETING_VERSION = 2.17.1;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -9,6 +9,7 @@ enum AppStorageKeys {
9 static let systemStatusSnapshotCache = "systemStatusSnapshotCache" 9 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
10 static let systemStatusIncidentCache = "systemStatusIncidentCache" 10 static let systemStatusIncidentCache = "systemStatusIncidentCache"
11 static let homeProjectsExpanded = "homeProjectsExpanded" 11 static let homeProjectsExpanded = "homeProjectsExpanded"
12 static let pinnedHomeProjects = "pinnedHomeProjects"
12 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" 13 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
13 static let homeBuildsExpanded = "homeBuildsExpanded" 14 static let homeBuildsExpanded = "homeBuildsExpanded"
14 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" 15 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
Hutch/App/RootView.swift +3
@@ -302,6 +302,7 @@ enum MoreDestination: Hashable {
302 302
303enum MoreRoute: Hashable { 303enum MoreRoute: Hashable {
304 case lookup 304 case lookup
305 case projects
305 case lists 306 case lists
306 case pastes 307 case pastes
307 case profile 308 case profile
@@ -320,6 +321,8 @@ private struct MoreNavigationRoot: View {
320 switch route { 321 switch route {
321 case .lookup: 322 case .lookup:
322 LookupView() 323 LookupView()
324 case .projects:
325 ProjectsListView()
323 case .lists: 326 case .lists:
324 MailingListListView() 327 MailingListListView()
325 case .pastes: 328 case .pastes:
Hutch/Models/Project.swift +27
@@ -69,6 +69,33 @@ struct Project: Identifiable, Hashable, Sendable {
69 let mailingLists: [MailingList] 69 let mailingLists: [MailingList]
70 let sources: [SourceRepo] 70 let sources: [SourceRepo]
71 let trackers: [Tracker] 71 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 }
72 99
73 var resourceSummary: String? { 100 var resourceSummary: String? {
74 let parts = [ 101 let parts = [
Hutch/Networking/ProjectService.swift +28 −20
@@ -34,6 +34,7 @@ private struct ProjectDetailPayload: Decodable, Sendable {
34 let website: String? 34 let website: String?
35 let visibility: Visibility 35 let visibility: Visibility
36 let tags: [String] 36 let tags: [String]
37 let updated: Date
37 let mailingLists: ProjectMailingListPage 38 let mailingLists: ProjectMailingListPage
38 let sources: ProjectSourcePage 39 let sources: ProjectSourcePage
39 let trackers: ProjectTrackerPage 40 let trackers: ProjectTrackerPage
@@ -110,6 +111,7 @@ struct ProjectService: Sendable {
110 website 111 website
111 visibility 112 visibility
112 tags 113 tags
114 updated
113 mailingLists(cursor: $mailingListsCursor) { 115 mailingLists(cursor: $mailingListsCursor) {
114 results { 116 results {
115 rid 117 rid
@@ -150,22 +152,11 @@ struct ProjectService: Sendable {
150 } 152 }
151 153
152 func fetchProjects() async throws -> [Project] { 154 func fetchProjects() async throws -> [Project] {
153 let summaries = try await fetchProjectSummaries() 155 try await fetchProjectSummaries().map(Self.makeSummaryProject)
154 guard !summaries.isEmpty else { return [] } 156 }
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 }
162 157
163 var projects: [Project] = [] 158 func fetchProjectDetail(rid: String) async throws -> Project {
164 for try await project in group { 159 try await fetchProjectDetailPayload(rid: rid)
165 projects.append(project)
166 }
167 return projects.sorted { $0.updated > $1.updated }
168 }
169 } 160 }
170 161
171 private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { 162 private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
@@ -192,10 +183,10 @@ struct ProjectService: Sendable {
192 cursor = nextCursor 183 cursor = nextCursor
193 } 184 }
194 185
195 return results 186 return results.sorted { $0.updated > $1.updated }
196 } 187 }
197 188
198 private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project { 189 private func fetchProjectDetailPayload(rid: String) async throws -> Project {
199 var mailingLists: [Project.MailingList] = [] 190 var mailingLists: [Project.MailingList] = []
200 var sources: [Project.SourceRepo] = [] 191 var sources: [Project.SourceRepo] = []
201 var trackers: [Project.Tracker] = [] 192 var trackers: [Project.Tracker] = []
@@ -204,7 +195,7 @@ struct ProjectService: Sendable {
204 var trackersCursor: String? 195 var trackersCursor: String?
205 196
206 while true { 197 while true {
207 var variables: [String: any Sendable] = ["rid": summary.rid] 198 var variables: [String: any Sendable] = ["rid": rid]
208 if let mailingListsCursor { 199 if let mailingListsCursor {
209 variables["mailingListsCursor"] = mailingListsCursor 200 variables["mailingListsCursor"] = mailingListsCursor
210 } 201 }
@@ -269,15 +260,32 @@ struct ProjectService: Sendable {
269 website: project.website, 260 website: project.website,
270 visibility: project.visibility, 261 visibility: project.visibility,
271 tags: project.tags, 262 tags: project.tags,
272 updated: summary.updated, 263 updated: project.updated,
273 mailingLists: deduplicate(mailingLists), 264 mailingLists: deduplicate(mailingLists),
274 sources: deduplicate(sources), 265 sources: deduplicate(sources),
275 trackers: deduplicate(trackers) 266 trackers: deduplicate(trackers),
267 isFullyLoaded: true
276 ) 268 )
277 } 269 }
278 } 270 }
279 } 271 }
280 272
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
281 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { 289 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
282 var seen = Set<T.ID>() 290 var seen = Set<T.ID>()
283 return items.filter { item in 291 return items.filter { item in
Hutch/Views/Home/HomeView.swift +6 −32
@@ -69,11 +69,11 @@ struct HomeView: View {
69 .listStyle(.insetGrouped) 69 .listStyle(.insetGrouped)
70 .overlay { 70 .overlay {
71 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && 71 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 &&
73 viewModel.unreadInboxThreads.isEmpty { 73 viewModel.unreadInboxThreads.isEmpty {
74 SRHTLoadingStateView(message: "Loading Home…") 74 SRHTLoadingStateView(message: "Loading Home…")
75 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && 75 } 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 &&
77 viewModel.unreadInboxThreads.isEmpty && 77 viewModel.unreadInboxThreads.isEmpty &&
78 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { 78 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
79 ContentUnavailableView( 79 ContentUnavailableView(
@@ -179,17 +179,17 @@ struct HomeView: View {
179 179
180 @ViewBuilder 180 @ViewBuilder
181 private func projectsSection(_ viewModel: HomeViewModel) -> some View { 181 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
182 if !viewModel.projects.isEmpty { 182 if !viewModel.pinnedProjects.isEmpty {
183 HomeSectionView("Projects", isExpanded: $projectsExpanded) { 183 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
184 NavigationLink { 184 NavigationLink {
185 HomeProjectsListView(viewModel: viewModel) 185 ProjectsListView()
186 } label: { 186 } label: {
187 Text("See All") 187 Text("See All")
188 .font(.caption.weight(.medium)) 188 .font(.caption.weight(.medium))
189 } 189 }
190 .buttonStyle(.plain) 190 .buttonStyle(.plain)
191 } content: { 191 } content: {
192 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in 192 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
193 NavigationLink { 193 NavigationLink {
194 ProjectDetailView(project: project) 194 ProjectDetailView(project: project)
195 } label: { 195 } label: {
@@ -411,32 +411,6 @@ private struct HomeProjectRow: View {
411 } 411 }
412} 412}
413 413
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
440private struct HomeBuildRow: View { 414private struct HomeBuildRow: View {
441 @Environment(AppState.self) private var appState 415 @Environment(AppState.self) private var appState
442 let build: HomeBuildItem 416 let build: HomeBuildItem
Hutch/Views/Home/HomeViewModel.swift +13 −1
@@ -195,6 +195,10 @@ final class HomeViewModel {
195 private let ticketFetchConcurrencyLimit = 6 195 private let ticketFetchConcurrencyLimit = 6
196 private let inboxUnreadConcurrencyLimit = 4 196 private let inboxUnreadConcurrencyLimit = 4
197 197
198 private var currentUserKey: String {
199 currentUser.canonicalName
200 }
201
198 private static let jobsQuery = """ 202 private static let jobsQuery = """
199 query jobs { 203 query jobs {
200 jobs { 204 jobs {
@@ -392,7 +396,15 @@ final class HomeViewModel {
392 } 396 }
393 397
394 var hasDashboardContent: Bool { 398 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] }
396 } 408 }
397 409
398 var failedBuildCount: Int { 410 var failedBuildCount: Int {
Hutch/Views/Lookup/LookupView.swift +2
@@ -422,6 +422,8 @@ struct LookupView: View {
422 switch route { 422 switch route {
423 case .lookup: 423 case .lookup:
424 LookupView() 424 LookupView()
425 case .projects:
426 ProjectsListView()
425 case .lists: 427 case .lists:
426 MailingListListView() 428 MailingListListView()
427 case .pastes: 429 case .pastes:
Hutch/Views/More/MoreView.swift +4
@@ -19,6 +19,10 @@ struct MoreView: View {
19 } 19 }
20 20
21 Section("Other Services") { 21 Section("Other Services") {
22 NavigationLink(value: MoreRoute.projects) {
23 Label("Projects", systemImage: "square.stack.3d.up")
24 }
25
22 NavigationLink(value: MoreRoute.lists) { 26 NavigationLink(value: MoreRoute.lists) {
23 Label("Mailing Lists", systemImage: "list.bullet.rectangle") 27 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
24 } 28 }
Hutch/Views/Projects/ProjectDetailView.swift +151 −14
@@ -2,49 +2,124 @@ import SwiftUI
2 2
3struct ProjectDetailView: View { 3struct ProjectDetailView: View {
4 let project: Project 4 let project: Project
5
5 @Environment(AppState.self) private var appState 6 @Environment(AppState.self) private var appState
6 @Environment(\.dismiss) private var dismiss 7 @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 }
7 26
8 var body: some View { 27 var body: some View {
9 List { 28 List {
10 headerSection 29 headerSection
30 linksSection
11 repositoriesSection 31 repositoriesSection
12 trackersSection 32 trackersSection
13 mailingListsSection 33 mailingListsSection
14 } 34 }
15 .navigationTitle(project.name) 35 .themedList()
36 .navigationTitle(displayedProject.name)
16 .navigationBarTitleDisplayMode(.inline) 37 .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)
17 } 68 }
18 69
19 @ViewBuilder 70 @ViewBuilder
20 private var headerSection: some View { 71 private var headerSection: some View {
21 Section { 72 Section {
22 VStack(alignment: .leading, spacing: 8) { 73 VStack(alignment: .leading, spacing: 10) {
23 Text(project.name) 74 Text(displayedProject.name)
24 .font(.headline) 75 .font(.headline)
25 76
26 if let description = project.description, !description.isEmpty { 77 if let description = displayedProject.description, !description.isEmpty {
27 Text(description) 78 Text(description)
28 .font(.subheadline) 79 .font(.subheadline)
29 .foregroundStyle(.secondary) 80 .foregroundStyle(.secondary)
30 } 81 }
31 82
32 if let website = project.website, let url = URL(string: website) { 83 if !displayedProject.tags.isEmpty {
33 Link(destination: url) { 84 ScrollView(.horizontal, showsIndicators: false) {
34 Label(website, systemImage: "link") 85 HStack(spacing: 8) {
35 .font(.subheadline) 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 }
36 } 94 }
37 } 95 }
96
97 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
38 } 98 }
39 .padding(.vertical, 4) 99 .padding(.vertical, 4)
40 } 100 }
41 } 101 }
42 102
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
43 @ViewBuilder 118 @ViewBuilder
44 private var repositoriesSection: some View { 119 private var repositoriesSection: some View {
45 if !project.sources.isEmpty { 120 if !displayedProject.sources.isEmpty {
46 Section("Repositories") { 121 Section("Repositories") {
47 ForEach(project.sources) { source in 122 ForEach(displayedProject.sources) { source in
48 Button { 123 Button {
49 Task { 124 Task {
50 try? await appState.openProjectSource(source) 125 try? await appState.openProjectSource(source)
@@ -65,9 +140,9 @@ struct ProjectDetailView: View {
65 140
66 @ViewBuilder 141 @ViewBuilder
67 private var trackersSection: some View { 142 private var trackersSection: some View {
68 if !project.trackers.isEmpty { 143 if !displayedProject.trackers.isEmpty {
69 Section("Trackers") { 144 Section("Trackers") {
70 ForEach(project.trackers) { tracker in 145 ForEach(displayedProject.trackers) { tracker in
71 Button { 146 Button {
72 Task { 147 Task {
73 try? await appState.openProjectTracker(tracker) 148 try? await appState.openProjectTracker(tracker)
@@ -88,9 +163,9 @@ struct ProjectDetailView: View {
88 163
89 @ViewBuilder 164 @ViewBuilder
90 private var mailingListsSection: some View { 165 private var mailingListsSection: some View {
91 if !project.mailingLists.isEmpty { 166 if !displayedProject.mailingLists.isEmpty {
92 Section("Mailing Lists") { 167 Section("Mailing Lists") {
93 ForEach(project.mailingLists) { mailingList in 168 ForEach(displayedProject.mailingLists) { mailingList in
94 Button { 169 Button {
95 appState.openMailingList(mailingList.inboxReference) 170 appState.openMailingList(mailingList.inboxReference)
96 dismiss() 171 dismiss()
@@ -106,6 +181,68 @@ struct ProjectDetailView: View {
106 } 181 }
107 } 182 }
108 } 183 }
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
109} 246}
110 247
111private struct ProjectResourceRow: View { 248private 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}