Commit 0c4b027468

0c4b0274687b483f8ec96aea0b8cce3aa89ff25b

parent: 30bac29e44

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 03:06 UTC

feat: polish read-only project views and home integration

Refs: https://todo.sr.ht/~ccleberg/hutch/36
Refs: https://todo.sr.ht/~ccleberg/hutch/37
Refs: https://todo.sr.ht/~ccleberg/hutch/38

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 = 41;
518 CURRENT_PROJECT_VERSION = 43;
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.18.0;
535 MARKETING_VERSION = 2.19.0;
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 = 41;
555 CURRENT_PROJECT_VERSION = 43;
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.18.0;
572 MARKETING_VERSION = 2.19.0;
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 = 41;
635 CURRENT_PROJECT_VERSION = 43;
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.18.0;
645 MARKETING_VERSION = 2.19.0;
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 = 41;
664 CURRENT_PROJECT_VERSION = 43;
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.18.0;
674 MARKETING_VERSION = 2.19.0;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Extensions/SRHTWebURL.swift +16
@@ -63,6 +63,22 @@ enum SRHTWebURL {
6363 )
6464 }
6565
66 static func projectSource(_ source: Project.SourceRepo) -> URL? {
67 userScopedURL(
68 host: "\(source.repoType.service.rawValue).sr.ht",
69 ownerUsername: source.ownerUsername,
70 pathComponents: [source.name]
71 )
72 }
73
74 static func mailingList(ownerUsername: String, listName: String) -> URL? {
75 userScopedURL(
76 host: "lists.sr.ht",
77 ownerUsername: ownerUsername,
78 pathComponents: [listName]
79 )
80 }
81
6682 static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? {
6783 userScopedURL(
6884 host: "todo.sr.ht",
Hutch/Models/Project.swift +106
@@ -115,11 +115,104 @@ struct Project: Identifiable, Hashable, Sendable {
115115 return nil
116116 }
117117
118 var displayName: String {
119 Self.normalizedText(name) ?? "Untitled Project"
120 }
121
122 var displayDescription: String? {
123 Self.normalizedText(description)
124 }
125
126 var displayTags: [String] {
127 var seen = Set<String>()
128 return tags.compactMap { tag in
129 guard let normalized = Self.normalizedText(tag) else { return nil }
130 let key = normalized.lowercased()
131 guard seen.insert(key).inserted else { return nil }
132 return normalized
133 }
134 }
135
136 var websiteURL: URL? {
137 guard let website = Self.normalizedText(website) else { return nil }
138 return URL(string: website)
139 }
140
141 var hasLinkedResources: Bool {
142 !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
143 }
144
145 var metadataLine: String {
146 var parts = [visibility.displayName, updated.relativeDescription]
147 if let summary = resourceSummary {
148 parts.append(summary)
149 }
150 return parts.joined(separator: " • ")
151 }
152
118153 private static func resourceCountText(count: Int, singular: String) -> String? {
119154 guard count > 0 else { return nil }
120155 let label = count == 1 ? singular : "\(singular)s"
121156 return "\(count) \(label)"
122157 }
158
159 fileprivate static func normalizedText(_ value: String?) -> String? {
160 guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
161 !trimmed.isEmpty else {
162 return nil
163 }
164 return trimmed
165 }
166}
167
168extension Project.MailingList {
169 var displayName: String {
170 Project.normalizedText(name) ?? "Untitled Mailing List"
171 }
172
173 var displayDescription: String? {
174 Project.normalizedText(description)
175 }
176
177 var ownerDisplayName: String {
178 Project.normalizedText(owner.canonicalName) ?? "~unknown"
179 }
180}
181
182extension Project.SourceRepo {
183 var displayName: String {
184 Project.normalizedText(name) ?? "Untitled Repository"
185 }
186
187 var displayDescription: String? {
188 Project.normalizedText(description)
189 }
190
191 var ownerDisplayName: String {
192 Project.normalizedText(owner.canonicalName) ?? "~unknown"
193 }
194
195 var webURL: URL? {
196 SRHTWebURL.projectSource(self)
197 }
198}
199
200extension Project.Tracker {
201 var displayName: String {
202 Project.normalizedText(name) ?? "Untitled Tracker"
203 }
204
205 var displayDescription: String? {
206 Project.normalizedText(description)
207 }
208
209 var ownerDisplayName: String {
210 Project.normalizedText(owner.canonicalName) ?? "~unknown"
211 }
212
213 var webURL: URL? {
214 SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
215 }
123216}
124217
125218extension String {
@@ -127,3 +220,16 @@ extension String {
127220 hasPrefix("~") ? String(dropFirst()) : self
128221 }
129222}
223
224extension Visibility {
225 var displayName: String {
226 switch self {
227 case .public:
228 "Public"
229 case .unlisted:
230 "Unlisted"
231 case .private:
232 "Private"
233 }
234 }
235}
Hutch/Networking/ProjectService.swift +118
@@ -11,6 +11,17 @@ private struct ProjectPageUser: Decodable, Sendable {
1111private struct ProjectPage: Decodable, Sendable {
1212 let results: [ProjectSummaryPayload]
1313 let cursor: String?
14
15 private enum CodingKeys: String, CodingKey {
16 case results
17 case cursor
18 }
19
20 init(from decoder: any Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22 results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
23 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
24 }
1425}
1526
1627private struct ProjectSummaryPayload: Decodable, Sendable {
@@ -21,6 +32,27 @@ private struct ProjectSummaryPayload: Decodable, Sendable {
2132 let visibility: Visibility
2233 let tags: [String]
2334 let updated: Date
35
36 private enum CodingKeys: String, CodingKey {
37 case rid
38 case name
39 case description
40 case website
41 case visibility
42 case tags
43 case updated
44 }
45
46 init(from decoder: any Decoder) throws {
47 let container = try decoder.container(keyedBy: CodingKeys.self)
48 rid = try container.decode(String.self, forKey: .rid)
49 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
50 description = try container.decodeIfPresent(String.self, forKey: .description)
51 website = try container.decodeIfPresent(String.self, forKey: .website)
52 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
53 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
54 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
55 }
2456}
2557
2658private struct ProjectDetailResponse: Decodable, Sendable {
@@ -38,11 +70,40 @@ private struct ProjectDetailPayload: Decodable, Sendable {
3870 let mailingLists: ProjectMailingListPage
3971 let sources: ProjectSourcePage
4072 let trackers: ProjectTrackerPage
73
74 private enum CodingKeys: String, CodingKey {
75 case rid
76 case name
77 case description
78 case website
79 case visibility
80 case tags
81 case updated
82 case mailingLists
83 case sources
84 case trackers
85 }
86
87 init(from decoder: any Decoder) throws {
88 let container = try decoder.container(keyedBy: CodingKeys.self)
89 rid = try container.decode(String.self, forKey: .rid)
90 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
91 description = try container.decodeIfPresent(String.self, forKey: .description)
92 website = try container.decodeIfPresent(String.self, forKey: .website)
93 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
94 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
95 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
96 mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
97 sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
98 trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
99 }
41100}
42101
43102private struct ProjectMailingListPage: Decodable, Sendable {
44103 let results: [ProjectMailingListPayload]
45104 let cursor: String?
105
106 static let empty = ProjectMailingListPage(results: [], cursor: nil)
46107}
47108
48109private struct ProjectMailingListPayload: Decodable, Sendable {
@@ -51,11 +112,30 @@ private struct ProjectMailingListPayload: Decodable, Sendable {
51112 let description: String?
52113 let visibility: Visibility
53114 let owner: Entity
115
116 private enum CodingKeys: String, CodingKey {
117 case rid
118 case name
119 case description
120 case visibility
121 case owner
122 }
123
124 init(from decoder: any Decoder) throws {
125 let container = try decoder.container(keyedBy: CodingKeys.self)
126 rid = try container.decode(String.self, forKey: .rid)
127 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
128 description = try container.decodeIfPresent(String.self, forKey: .description)
129 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
130 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
131 }
54132}
55133
56134private struct ProjectSourcePage: Decodable, Sendable {
57135 let results: [ProjectSourcePayload]
58136 let cursor: String?
137
138 static let empty = ProjectSourcePage(results: [], cursor: nil)
59139}
60140
61141private struct ProjectSourcePayload: Decodable, Sendable {
@@ -65,11 +145,32 @@ private struct ProjectSourcePayload: Decodable, Sendable {
65145 let visibility: Visibility
66146 let owner: Entity
67147 let repoType: Project.SourceRepo.RepoType
148
149 private enum CodingKeys: String, CodingKey {
150 case rid
151 case name
152 case description
153 case visibility
154 case owner
155 case repoType
156 }
157
158 init(from decoder: any Decoder) throws {
159 let container = try decoder.container(keyedBy: CodingKeys.self)
160 rid = try container.decode(String.self, forKey: .rid)
161 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
162 description = try container.decodeIfPresent(String.self, forKey: .description)
163 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
164 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
165 repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
166 }
68167}
69168
70169private struct ProjectTrackerPage: Decodable, Sendable {
71170 let results: [ProjectTrackerPayload]
72171 let cursor: String?
172
173 static let empty = ProjectTrackerPage(results: [], cursor: nil)
73174}
74175
75176private struct ProjectTrackerPayload: Decodable, Sendable {
@@ -78,6 +179,23 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
78179 let description: String?
79180 let visibility: Visibility
80181 let owner: Entity
182
183 private enum CodingKeys: String, CodingKey {
184 case rid
185 case name
186 case description
187 case visibility
188 case owner
189 }
190
191 init(from decoder: any Decoder) throws {
192 let container = try decoder.container(keyedBy: CodingKeys.self)
193 rid = try container.decode(String.self, forKey: .rid)
194 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
195 description = try container.decodeIfPresent(String.self, forKey: .description)
196 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
197 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
198 }
81199}
82200
83201struct ProjectService: Sendable {
Hutch/Views/Home/HomeView.swift +46 −22
@@ -179,7 +179,7 @@ struct HomeView: View {
179179
180180 @ViewBuilder
181181 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
182 if !viewModel.pinnedProjects.isEmpty {
182 if viewModel.hasPinnedProjects {
183183 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
184184 NavigationLink {
185185 ProjectsListView()
@@ -189,11 +189,27 @@ struct HomeView: View {
189189 }
190190 .buttonStyle(.plain)
191191 } content: {
192 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
193 NavigationLink {
194 ProjectDetailView(project: project)
195 } label: {
196 HomeProjectRow(project: project)
192 if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
193 HomeSectionLoadingRow(label: "Loading pinned projects")
194 } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
195 HomeSectionMessageRow(
196 text: "Couldn’t load pinned projects.",
197 systemImage: "exclamationmark.triangle",
198 emphasized: true,
199 accessibilityHint: error
200 )
201 } else if viewModel.pinnedProjects.isEmpty {
202 HomeSectionMessageRow(
203 text: "Pinned projects will appear here when they’re available.",
204 systemImage: "pin"
205 )
206 } else {
207 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
208 NavigationLink {
209 ProjectDetailView(project: project)
210 } label: {
211 HomeProjectRow(project: project)
212 }
197213 }
198214 }
199215 }
@@ -388,26 +404,34 @@ private struct HomeProjectRow: View {
388404 let project: Project
389405
390406 var body: some View {
391 VStack(alignment: .leading, spacing: 4) {
392 Text(project.name)
393 .font(.subheadline.weight(.medium))
394 .lineLimit(1)
407 VStack(alignment: .leading, spacing: 8) {
408 HStack(alignment: .top, spacing: 10) {
409 VStack(alignment: .leading, spacing: 4) {
410 Text(project.displayName)
411 .font(.subheadline.weight(.medium))
412 .foregroundStyle(.primary)
413 .lineLimit(1)
395414
396 if let description = project.description, !description.isEmpty {
397 Text(description)
398 .font(.caption)
399 .foregroundStyle(.secondary)
400 .lineLimit(1)
401 }
415 if let description = project.displayDescription {
416 Text(description)
417 .font(.caption)
418 .foregroundStyle(.secondary)
419 .lineLimit(2)
420 }
421 }
402422
403 if let summary = project.resourceSummary {
404 Text(summary)
405 .font(.caption)
406 .foregroundStyle(.tertiary)
407 .lineLimit(1)
423 Spacer(minLength: 8)
424
425 VisibilityBadge(visibility: project.visibility)
408426 }
427
428 Text(project.metadataLine)
429 .font(.caption)
430 .foregroundStyle(.secondary)
431 .lineLimit(1)
409432 }
410 .padding(.vertical, 2)
433 .contentShape(Rectangle())
434 .padding(.vertical, 4)
411435 }
412436}
413437
Hutch/Views/Home/HomeViewModel.swift +9 −2
@@ -185,6 +185,7 @@ final class HomeViewModel {
185185 private(set) var isLoadingProjects = false
186186 private(set) var isLoadingAssignedTickets = false
187187 private(set) var isLoadingRecentBuilds = false
188 private(set) var projectsError: String?
188189 private(set) var assignedTicketsError: String?
189190 private(set) var recentBuildsError: String?
190191
@@ -332,6 +333,7 @@ final class HomeViewModel {
332333 isLoadingAssignedTickets = true
333334 isLoadingRecentBuilds = true
334335 isLoadingSystemStatus = true
336 projectsError = nil
335337 assignedTicketsError = nil
336338 recentBuildsError = nil
337339 isShowingStaleSystemStatus = false
@@ -347,8 +349,9 @@ final class HomeViewModel {
347349 switch projectsResult {
348350 case .success(let projects):
349351 self.projects = projects
350 case .failure:
351 self.projects = []
352 self.projectsError = nil
353 case .failure(let error):
354 self.projectsError = error.userFacingMessage
352355 }
353356 isLoadingProjects = false
354357
@@ -407,6 +410,10 @@ final class HomeViewModel {
407410 return pinnedIDs.compactMap { projectsByID[$0] }
408411 }
409412
413 var hasPinnedProjects: Bool {
414 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
415 }
416
410417 var failedBuildCount: Int {
411418 recentBuilds.filter {
412419 switch $0.job.status {
Hutch/Views/Projects/ProjectDetailView.swift +101 −51
@@ -5,6 +5,7 @@ struct ProjectDetailView: View {
55
66 @Environment(AppState.self) private var appState
77 @Environment(\.dismiss) private var dismiss
8 @Environment(\.openURL) private var openURL
89 @State private var detailProject: Project?
910 @State private var isLoading = false
1011 @State private var error: String?
@@ -27,13 +28,14 @@ struct ProjectDetailView: View {
2728 var body: some View {
2829 List {
2930 headerSection
30 linksSection
3131 repositoriesSection
3232 trackersSection
3333 mailingListsSection
34 linksSection
35 emptyResourcesSection
3436 }
3537 .themedList()
36 .navigationTitle(displayedProject.name)
38 .navigationTitle(displayedProject.displayName)
3739 .navigationBarTitleDisplayMode(.inline)
3840 .overlay {
3941 if isLoading, detailProject == nil, !project.isFullyLoaded {
@@ -71,19 +73,19 @@ struct ProjectDetailView: View {
7173 private var headerSection: some View {
7274 Section {
7375 VStack(alignment: .leading, spacing: 10) {
74 Text(displayedProject.name)
76 Text(displayedProject.displayName)
7577 .font(.headline)
7678
77 if let description = displayedProject.description, !description.isEmpty {
79 if let description = displayedProject.displayDescription {
7880 Text(description)
7981 .font(.subheadline)
8082 .foregroundStyle(.secondary)
8183 }
8284
83 if !displayedProject.tags.isEmpty {
85 if !displayedProject.displayTags.isEmpty {
8486 ScrollView(.horizontal, showsIndicators: false) {
8587 HStack(spacing: 8) {
86 ForEach(displayedProject.tags, id: \.self) { tag in
88 ForEach(displayedProject.displayTags, id: \.self) { tag in
8789 Text(tag)
8890 .font(.caption.weight(.medium))
8991 .padding(.horizontal, 10)
@@ -94,7 +96,11 @@ struct ProjectDetailView: View {
9496 }
9597 }
9698
99 LabeledContent("Project", value: displayedProject.visibility.displayName)
97100 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
101 if let summary = displayedProject.resourceSummary {
102 LabeledContent("Linked", value: summary)
103 }
98104 }
99105 .padding(.vertical, 4)
100106 }
@@ -106,10 +112,20 @@ struct ProjectDetailView: View {
106112 if !links.isEmpty {
107113 Section("Links") {
108114 ForEach(links) { link in
109 Link(destination: link.url) {
110 Label(link.title, systemImage: link.systemImage)
111 .font(.subheadline)
115 Button {
116 openURL(link.url)
117 } label: {
118 HStack(spacing: 12) {
119 Label(link.title, systemImage: link.systemImage)
120 .font(.subheadline)
121 .foregroundStyle(.primary)
122 Spacer()
123 Image(systemName: "arrow.up.right")
124 .font(.caption.weight(.semibold))
125 .foregroundStyle(.tertiary)
126 }
112127 }
128 .buttonStyle(.plain)
113129 }
114130 }
115131 }
@@ -122,14 +138,19 @@ struct ProjectDetailView: View {
122138 ForEach(displayedProject.sources) { source in
123139 Button {
124140 Task {
125 try? await appState.openProjectSource(source)
126 dismiss()
141 do {
142 try await appState.openProjectSource(source)
143 dismiss()
144 } catch {
145 self.error = "Couldn’t open repository. \(error.userFacingMessage)"
146 }
127147 }
128148 } label: {
129149 ProjectResourceRow(
130 title: source.name,
131 subtitle: source.owner.canonicalName,
132 detail: source.description
150 title: source.displayName,
151 subtitle: source.ownerDisplayName,
152 detail: source.displayDescription,
153 systemImage: "book.closed"
133154 )
134155 }
135156 .buttonStyle(.plain)
@@ -145,14 +166,19 @@ struct ProjectDetailView: View {
145166 ForEach(displayedProject.trackers) { tracker in
146167 Button {
147168 Task {
148 try? await appState.openProjectTracker(tracker)
149 dismiss()
169 do {
170 try await appState.openProjectTracker(tracker)
171 dismiss()
172 } catch {
173 self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
174 }
150175 }
151176 } label: {
152177 ProjectResourceRow(
153 title: tracker.name,
154 subtitle: tracker.owner.canonicalName,
155 detail: tracker.description
178 title: tracker.displayName,
179 subtitle: tracker.ownerDisplayName,
180 detail: tracker.displayDescription,
181 systemImage: "checklist"
156182 )
157183 }
158184 .buttonStyle(.plain)
@@ -171,9 +197,10 @@ struct ProjectDetailView: View {
171197 dismiss()
172198 } label: {
173199 ProjectResourceRow(
174 title: mailingList.name,
175 subtitle: mailingList.owner.canonicalName,
176 detail: mailingList.description
200 title: mailingList.displayName,
201 subtitle: mailingList.ownerDisplayName,
202 detail: mailingList.displayDescription,
203 systemImage: "list.bullet"
177204 )
178205 }
179206 .buttonStyle(.plain)
@@ -182,6 +209,19 @@ struct ProjectDetailView: View {
182209 }
183210 }
184211
212 @ViewBuilder
213 private var emptyResourcesSection: some View {
214 if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
215 Section {
216 ContentUnavailableView(
217 "No Linked Resources",
218 systemImage: "square.stack.3d.up.slash",
219 description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
220 )
221 }
222 }
223 }
224
185225 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
186226 guard forceRefresh || !project.isFullyLoaded else {
187227 detailProject = project
@@ -197,7 +237,7 @@ struct ProjectDetailView: View {
197237 let service = ProjectService(client: appState.client)
198238 detailProject = try await service.fetchProjectDetail(rid: project.id)
199239 } catch {
200 self.error = "Failed to load project"
240 self.error = "Couldn’t load project. \(error.userFacingMessage)"
201241 }
202242 }
203243
@@ -210,31 +250,26 @@ struct ProjectDetailView: View {
210250 private func projectLinks(for project: Project) -> [ProjectLink] {
211251 var links: [ProjectLink] = []
212252
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))
253 if let url = project.websiteURL {
254 links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
217255 }
218256
219257 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))
258 let url = source.webURL {
259 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
222260 }
223261
224262 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))
263 let url = tracker.webURL {
264 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
227265 }
228266
229 return links
230 }
267 if let mailingList = project.mailingLists.first,
268 let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
269 links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
270 }
231271
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
272 return links
238273 }
239274}
240275
@@ -249,24 +284,39 @@ private struct ProjectResourceRow: View {
249284 let title: String
250285 let subtitle: String
251286 let detail: String?
287 let systemImage: String
252288
253289 var body: some View {
254 VStack(alignment: .leading, spacing: 4) {
255 Text(title)
256 .font(.subheadline.weight(.medium))
257
258 Text(subtitle)
259 .font(.caption)
290 HStack(alignment: .top, spacing: 12) {
291 Image(systemName: systemImage)
292 .frame(width: 18, alignment: .leading)
260293 .foregroundStyle(.secondary)
261 .lineLimit(1)
262294
263 if let detail, !detail.isEmpty {
264 Text(detail)
295 VStack(alignment: .leading, spacing: 4) {
296 Text(title)
297 .font(.subheadline.weight(.medium))
298 .foregroundStyle(.primary)
299
300 Text(subtitle)
265301 .font(.caption)
266 .foregroundStyle(.tertiary)
267 .lineLimit(2)
302 .foregroundStyle(.secondary)
303 .lineLimit(1)
304
305 if let detail, !detail.isEmpty {
306 Text(detail)
307 .font(.caption)
308 .foregroundStyle(.tertiary)
309 .lineLimit(2)
310 }
268311 }
312
313 Spacer(minLength: 8)
314
315 Image(systemName: "chevron.right")
316 .font(.caption.weight(.semibold))
317 .foregroundStyle(.tertiary)
269318 }
270 .padding(.vertical, 2)
319 .contentShape(Rectangle())
320 .padding(.vertical, 4)
271321 }
272322}
Hutch/Views/Projects/ProjectPinStore.swift +28 −4
@@ -6,7 +6,7 @@ enum ProjectPinStore {
66 defaults: UserDefaults = .standard
77 ) -> [String] {
88 let pinnedProjects = loadAll(defaults: defaults)
9 return pinnedProjects[userKey] ?? []
9 return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
1010 }
1111
1212 static func isPinned(
@@ -23,12 +23,14 @@ enum ProjectPinStore {
2323 defaults: UserDefaults = .standard
2424 ) {
2525 var pinnedProjects = loadAll(defaults: defaults)
26 var projectIDs = pinnedProjects[userKey] ?? []
26 var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
2727
2828 if let index = projectIDs.firstIndex(of: projectID) {
2929 projectIDs.remove(at: index)
3030 } else {
31 projectIDs.append(projectID)
31 if let normalizedProjectID = normalizedProjectID(projectID) {
32 projectIDs.append(normalizedProjectID)
33 }
3234 }
3335
3436 pinnedProjects[userKey] = projectIDs
@@ -40,11 +42,33 @@ enum ProjectPinStore {
4042 return [:]
4143 }
4244
43 return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
45 let decoded = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
46 return decoded.reduce(into: [String: [String]]()) { result, entry in
47 let normalizedUserKey = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
48 guard !normalizedUserKey.isEmpty else { return }
49 let normalizedProjectIDs = normalizedProjectIDs(entry.value)
50 if !normalizedProjectIDs.isEmpty {
51 result[normalizedUserKey] = normalizedProjectIDs
52 }
53 }
4454 }
4555
4656 private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
4757 guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
4858 defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
4959 }
60
61 private static func normalizedProjectIDs(_ projectIDs: [String]) -> [String] {
62 var seen = Set<String>()
63 return projectIDs.compactMap { projectID in
64 guard let normalizedProjectID = normalizedProjectID(projectID) else { return nil }
65 guard seen.insert(normalizedProjectID).inserted else { return nil }
66 return normalizedProjectID
67 }
68 }
69
70 private static func normalizedProjectID(_ projectID: String) -> String? {
71 let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
72 return trimmed.isEmpty ? nil : trimmed
73 }
5074}
Hutch/Views/Projects/ProjectsListView.swift +46 −24
@@ -34,7 +34,11 @@ final class ProjectsListViewModel {
3434 do {
3535 projects = try await service.fetchProjects()
3636 } catch {
37 self.error = "Failed to load projects"
37 if projects.isEmpty {
38 self.error = error.userFacingMessage
39 } else {
40 self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
41 }
3842 }
3943 }
4044}
@@ -72,6 +76,8 @@ struct ProjectsListView: View {
7276 } label: {
7377 ProjectListRow(project: project)
7478 }
79 .buttonStyle(.plain)
80 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
7581 }
7682 }
7783 .themedList()
@@ -96,7 +102,7 @@ struct ProjectsListView: View {
96102 ContentUnavailableView(
97103 "No Projects",
98104 systemImage: "square.stack.3d.up",
99 description: Text("Your SourceHut projects will appear here.")
105 description: Text("Projects from your SourceHut account will appear here when available.")
100106 )
101107 }
102108 }
@@ -114,33 +120,49 @@ private struct ProjectListRow: View {
114120 let project: Project
115121
116122 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()
123 VStack(alignment: .leading, spacing: 6) {
124 HStack(alignment: .top, spacing: 10) {
125 VStack(alignment: .leading, spacing: 4) {
126 Text(project.displayName)
127 .font(.subheadline.weight(.medium))
128 .foregroundStyle(.primary)
129 .lineLimit(1)
130
131 if let description = project.displayDescription {
132 Text(description)
133 .font(.caption)
134 .foregroundStyle(.secondary)
135 .lineLimit(2)
136 }
137 }
124138
125 Text(project.updated.relativeDescription)
126 .font(.caption)
127 .foregroundStyle(.tertiary)
128 }
139 Spacer(minLength: 8)
129140
130 if let description = project.description, !description.isEmpty {
131 Text(description)
132 .font(.caption)
133 .foregroundStyle(.secondary)
134 .lineLimit(2)
141 VisibilityBadge(visibility: project.visibility)
135142 }
136143
137 if let summary = project.resourceSummary {
138 Text(summary)
139 .font(.caption)
140 .foregroundStyle(.tertiary)
141 .lineLimit(1)
144 Text(project.metadataLine)
145 .font(.caption)
146 .foregroundStyle(.secondary)
147 .lineLimit(1)
148
149 if !project.displayTags.isEmpty {
150 ScrollView(.horizontal, showsIndicators: false) {
151 HStack(spacing: 6) {
152 ForEach(project.displayTags.prefix(4), id: \.self) { tag in
153 Text(tag)
154 .font(.caption2.weight(.medium))
155 .foregroundStyle(.secondary)
156 .padding(.horizontal, 8)
157 .padding(.vertical, 3)
158 .background(.quaternary, in: Capsule())
159 }
160 }
161 }
162 .scrollDisabled(true)
142163 }
143164 }
144 .padding(.vertical, 2)
165 .contentShape(Rectangle())
166 .padding(.vertical, 4)
145167 }
146168}
Hutch/Views/Tickets/TicketListView.swift +133 −76
@@ -194,75 +194,67 @@ struct TicketListView: View {
194194
195195 List {
196196 Section {
197 VStack(alignment: .leading, spacing: 12) {
198 Picker("Filter", selection: $vm.filter) {
199 ForEach(TicketFilter.allCases, id: \.self) { filter in
200 Text(filter.rawValue).tag(filter)
201 }
202 }
203 .pickerStyle(.segmented)
204
205 TicketQuickFilterBar(
206 selectedLabels: viewModel.selectedLabels,
207 savedFilters: viewModel.savedFilters,
208 activeSavedFilterID: viewModel.activeSavedFilterID,
209 canSaveCurrentFilter: viewModel.hasCustomFilterSelection
210 ) {
211 showLabelFilterSheet = true
212 } onSaveFilter: {
213 showSaveFilterSheet = true
214 } onResetFilters: {
215 vm.resetFilters()
216 } onApplySavedFilter: { savedFilter in
217 vm.applySavedFilter(savedFilter)
218 } onDeleteSavedFilter: { savedFilter in
219 vm.deleteSavedFilter(savedFilter)
220 }
197 TicketListFilterHeader(
198 selectedLabels: viewModel.selectedLabels,
199 savedFilters: viewModel.savedFilters,
200 activeSavedFilterID: viewModel.activeSavedFilterID,
201 canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
202 filter: $vm.filter
203 ) {
204 showLabelFilterSheet = true
205 } onSaveFilter: {
206 showSaveFilterSheet = true
207 } onResetFilters: {
208 vm.resetFilters()
209 } onApplySavedFilter: { savedFilter in
210 vm.applySavedFilter(savedFilter)
211 } onDeleteSavedFilter: { savedFilter in
212 vm.deleteSavedFilter(savedFilter)
221213 }
222 .padding(.vertical, 4)
223 .listRowBackground(Color.clear)
224214 .listRowInsets(EdgeInsets())
225 }
215 .listRowBackground(Color.clear)
216 .listRowSeparator(.hidden)
226217
227 // Tickets
228 ForEach(viewModel.filteredTickets) { ticket in
229 NavigationLink {
230 TicketDetailView(
231 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
232 trackerName: tracker.name,
233 trackerId: tracker.id,
234 trackerRid: tracker.rid,
235 ticketId: ticket.id
236 )
237 } label: {
238 TicketRowView(ticket: ticket)
239 }
240 .swipeActions(edge: .leading, allowsFullSwipe: true) {
241 if swipeActionsEnabled {
242 ticketAssignSwipeAction(ticket, viewModel: viewModel)
218 ForEach(viewModel.filteredTickets) { ticket in
219 NavigationLink {
220 TicketDetailView(
221 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
222 trackerName: tracker.name,
223 trackerId: tracker.id,
224 trackerRid: tracker.rid,
225 ticketId: ticket.id
226 )
227 } label: {
228 TicketRowView(ticket: ticket)
243229 }
244 }
245 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
246 if swipeActionsEnabled {
247 ticketStatusSwipeAction(ticket, viewModel: viewModel)
248 ticketLabelSwipeAction(ticket, viewModel: viewModel)
230 .swipeActions(edge: .leading, allowsFullSwipe: true) {
231 if swipeActionsEnabled {
232 ticketAssignSwipeAction(ticket, viewModel: viewModel)
233 }
234 }
235 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
236 if swipeActionsEnabled {
237 ticketStatusSwipeAction(ticket, viewModel: viewModel)
238 ticketLabelSwipeAction(ticket, viewModel: viewModel)
239 }
240 }
241 .task {
242 await viewModel.loadMoreIfNeeded(currentItem: ticket)
249243 }
250244 }
251 .task {
252 await viewModel.loadMoreIfNeeded(currentItem: ticket)
253 }
254 }
255245
256 if viewModel.isLoadingMore {
257 HStack {
258 Spacer()
259 ProgressView()
260 Spacer()
246 if viewModel.isLoadingMore {
247 HStack {
248 Spacer()
249 ProgressView()
250 Spacer()
251 }
252 .listRowSeparator(.hidden)
261253 }
262 .listRowSeparator(.hidden)
263254 }
264255 }
265256 .listStyle(.plain)
257 .listSectionSpacing(.compact)
266258 .searchable(
267259 text: $vm.searchText,
268260 placement: .navigationBarDrawer(displayMode: .always),
@@ -538,11 +530,12 @@ private struct TicketLabelsSheet: View {
538530 }
539531}
540532
541private struct TicketQuickFilterBar: View {
533private struct TicketListFilterHeader: View {
542534 let selectedLabels: [TicketLabel]
543535 let savedFilters: [SavedTicketFilter]
544536 let activeSavedFilterID: SavedTicketFilter.ID?
545537 let canSaveCurrentFilter: Bool
538 @Binding var filter: TicketFilter
546539 let onShowLabels: () -> Void
547540 let onSaveFilter: () -> Void
548541 let onResetFilters: () -> Void
@@ -551,26 +544,45 @@ private struct TicketQuickFilterBar: View {
551544
552545 var body: some View {
553546 VStack(alignment: .leading, spacing: 10) {
554 HStack(spacing: 8) {
555 Button(action: onShowLabels) {
556 Label(labelButtonTitle, systemImage: "tag")
557 .font(.caption.weight(.medium))
547 Picker("Filter", selection: $filter) {
548 ForEach(TicketFilter.allCases, id: \.self) { filter in
549 Text(filter.rawValue).tag(filter)
558550 }
559 .buttonStyle(.bordered)
551 }
552 .pickerStyle(.segmented)
553
554 TicketQuickFilterBar(
555 selectedLabels: selectedLabels,
556 savedFilters: savedFilters,
557 activeSavedFilterID: activeSavedFilterID,
558 canSaveCurrentFilter: canSaveCurrentFilter,
559 onShowLabels: onShowLabels,
560 onSaveFilter: onSaveFilter,
561 onResetFilters: onResetFilters,
562 onApplySavedFilter: onApplySavedFilter,
563 onDeleteSavedFilter: onDeleteSavedFilter
564 )
565 }
566 .padding(.horizontal, 16)
567 .padding(.top, 6)
568 .padding(.bottom, 10)
569 }
570}
560571
561 Button(action: onSaveFilter) {
562 Label("Save Filter", systemImage: "square.and.arrow.down")
563 .font(.caption.weight(.medium))
564 }
565 .buttonStyle(.bordered)
566 .disabled(!canSaveCurrentFilter)
572private struct TicketQuickFilterBar: View {
573 let selectedLabels: [TicketLabel]
574 let savedFilters: [SavedTicketFilter]
575 let activeSavedFilterID: SavedTicketFilter.ID?
576 let canSaveCurrentFilter: Bool
577 let onShowLabels: () -> Void
578 let onSaveFilter: () -> Void
579 let onResetFilters: () -> Void
580 let onApplySavedFilter: (SavedTicketFilter) -> Void
581 let onDeleteSavedFilter: (SavedTicketFilter) -> Void
567582
568 if canSaveCurrentFilter {
569 Button("Reset", action: onResetFilters)
570 .font(.caption.weight(.medium))
571 .buttonStyle(.bordered)
572 }
573 }
583 var body: some View {
584 VStack(alignment: .leading, spacing: 8) {
585 actionButtons
574586
575587 if !selectedLabels.isEmpty {
576588 ScrollView(.horizontal, showsIndicators: false) {
@@ -623,6 +635,51 @@ private struct TicketQuickFilterBar: View {
623635 private var labelButtonTitle: String {
624636 selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
625637 }
638
639 @ViewBuilder
640 private var actionButtons: some View {
641 ViewThatFits(in: .horizontal) {
642 HStack(spacing: 8) {
643 labelsButton
644 saveFilterButton
645 if canSaveCurrentFilter {
646 resetButton
647 }
648 Spacer(minLength: 0)
649 }
650
651 FlowLayout(spacing: 8) {
652 labelsButton
653 saveFilterButton
654 if canSaveCurrentFilter {
655 resetButton
656 }
657 }
658 }
659 }
660
661 private var labelsButton: some View {
662 Button(action: onShowLabels) {
663 Label(labelButtonTitle, systemImage: "tag")
664 .font(.caption.weight(.medium))
665 }
666 .buttonStyle(.bordered)
667 }
668
669 private var saveFilterButton: some View {
670 Button(action: onSaveFilter) {
671 Label("Save Filter", systemImage: "square.and.arrow.down")
672 .font(.caption.weight(.medium))
673 }
674 .buttonStyle(.bordered)
675 .disabled(!canSaveCurrentFilter)
676 }
677
678 private var resetButton: some View {
679 Button("Reset", action: onResetFilters)
680 .font(.caption.weight(.medium))
681 .buttonStyle(.bordered)
682 }
626683}
627684
628685private struct TicketFilterLabelsSheet: View {
HutchTests/ProjectPinStoreTests.swift +13
@@ -25,4 +25,17 @@ struct ProjectPinStoreTests {
2525
2626 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
2727 }
28
29 @Test
30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
31 let defaults = UserDefaults(suiteName: #function)!
32 defaults.removePersistentDomain(forName: #function)
33
34 let encoded = try! JSONEncoder().encode([
35 "~alice": [" project-1 ", "", "project-1", "project-2"]
36 ])
37 defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
38
39 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1", "project-2"])
40 }
2841}
HutchTests/ProjectTests.swift +21
@@ -71,4 +71,25 @@ struct ProjectTests {
7171
7272 #expect(project.resourceSummary == "Website linked")
7373 }
74
75 @Test
76 func displayHelpersNormalizeBlankValues() {
77 let project = Project(
78 id: "project-1",
79 name: " ",
80 description: "\n",
81 website: "https://example.com",
82 visibility: .unlisted,
83 tags: [" docs ", "", "Docs", "ios"],
84 updated: Date(timeIntervalSince1970: 0),
85 mailingLists: [],
86 sources: [],
87 trackers: []
88 )
89
90 #expect(project.displayName == "Untitled Project")
91 #expect(project.displayDescription == nil)
92 #expect(project.displayTags == ["docs", "ios"])
93 #expect(project.metadataLine.contains("Unlisted"))
94 }
7495}