summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:06:16 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:06:16 -0500
commit86bcd9452d3107f68aca085e03e69b02667c0dbb (patch)
tree8b0adece5cb0f8ffb178eaab2cd12d025c9ebf0a
parent31cbca674957eea7ae2529012e20a9d95158e561 (diff)
downloadhutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.tar.gz
hutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.tar.bz2
hutch-86bcd9452d3107f68aca085e03e69b02667c0dbb.zip
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
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift16
-rw-r--r--Hutch/Models/Project.swift106
-rw-r--r--Hutch/Networking/ProjectService.swift118
-rw-r--r--Hutch/Views/Home/HomeView.swift68
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift11
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift152
-rw-r--r--Hutch/Views/Projects/ProjectPinStore.swift32
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift70
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift209
-rw-r--r--HutchTests/ProjectPinStoreTests.swift13
-rw-r--r--HutchTests/ProjectTests.swift21
12 files changed, 645 insertions, 187 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 57441b8..07c076b 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 41;
+ CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.18.0;
+ MARKETING_VERSION = 2.19.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 41;
+ CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.18.0;
+ MARKETING_VERSION = 2.19.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 41;
+ CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.18.0;
+ MARKETING_VERSION = 2.19.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 41;
+ CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.18.0;
+ MARKETING_VERSION = 2.19.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index 9bc1552..cd8e8be 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -63,6 +63,22 @@ enum SRHTWebURL {
)
}
+ static func projectSource(_ source: Project.SourceRepo) -> URL? {
+ userScopedURL(
+ host: "\(source.repoType.service.rawValue).sr.ht",
+ ownerUsername: source.ownerUsername,
+ pathComponents: [source.name]
+ )
+ }
+
+ static func mailingList(ownerUsername: String, listName: String) -> URL? {
+ userScopedURL(
+ host: "lists.sr.ht",
+ ownerUsername: ownerUsername,
+ pathComponents: [listName]
+ )
+ }
+
static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? {
userScopedURL(
host: "todo.sr.ht",
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 54c0318..678d52e 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -115,11 +115,104 @@ struct Project: Identifiable, Hashable, Sendable {
return nil
}
+ var displayName: String {
+ Self.normalizedText(name) ?? "Untitled Project"
+ }
+
+ var displayDescription: String? {
+ Self.normalizedText(description)
+ }
+
+ var displayTags: [String] {
+ var seen = Set<String>()
+ return tags.compactMap { tag in
+ guard let normalized = Self.normalizedText(tag) else { return nil }
+ let key = normalized.lowercased()
+ guard seen.insert(key).inserted else { return nil }
+ return normalized
+ }
+ }
+
+ var websiteURL: URL? {
+ guard let website = Self.normalizedText(website) else { return nil }
+ return URL(string: website)
+ }
+
+ var hasLinkedResources: Bool {
+ !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
+ }
+
+ var metadataLine: String {
+ var parts = [visibility.displayName, updated.relativeDescription]
+ if let summary = resourceSummary {
+ parts.append(summary)
+ }
+ return parts.joined(separator: " • ")
+ }
+
private static func resourceCountText(count: Int, singular: String) -> String? {
guard count > 0 else { return nil }
let label = count == 1 ? singular : "\(singular)s"
return "\(count) \(label)"
}
+
+ fileprivate static func normalizedText(_ value: String?) -> String? {
+ guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !trimmed.isEmpty else {
+ return nil
+ }
+ return trimmed
+ }
+}
+
+extension Project.MailingList {
+ var displayName: String {
+ Project.normalizedText(name) ?? "Untitled Mailing List"
+ }
+
+ var displayDescription: String? {
+ Project.normalizedText(description)
+ }
+
+ var ownerDisplayName: String {
+ Project.normalizedText(owner.canonicalName) ?? "~unknown"
+ }
+}
+
+extension Project.SourceRepo {
+ var displayName: String {
+ Project.normalizedText(name) ?? "Untitled Repository"
+ }
+
+ var displayDescription: String? {
+ Project.normalizedText(description)
+ }
+
+ var ownerDisplayName: String {
+ Project.normalizedText(owner.canonicalName) ?? "~unknown"
+ }
+
+ var webURL: URL? {
+ SRHTWebURL.projectSource(self)
+ }
+}
+
+extension Project.Tracker {
+ var displayName: String {
+ Project.normalizedText(name) ?? "Untitled Tracker"
+ }
+
+ var displayDescription: String? {
+ Project.normalizedText(description)
+ }
+
+ var ownerDisplayName: String {
+ Project.normalizedText(owner.canonicalName) ?? "~unknown"
+ }
+
+ var webURL: URL? {
+ SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
+ }
}
extension String {
@@ -127,3 +220,16 @@ extension String {
hasPrefix("~") ? String(dropFirst()) : self
}
}
+
+extension Visibility {
+ var displayName: String {
+ switch self {
+ case .public:
+ "Public"
+ case .unlisted:
+ "Unlisted"
+ case .private:
+ "Private"
+ }
+ }
+}
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 5a0b466..93ce71f 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -11,6 +11,17 @@ private struct ProjectPageUser: Decodable, Sendable {
private struct ProjectPage: Decodable, Sendable {
let results: [ProjectSummaryPayload]
let cursor: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case results
+ case cursor
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
+ cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
}
private struct ProjectSummaryPayload: Decodable, Sendable {
@@ -21,6 +32,27 @@ private struct ProjectSummaryPayload: Decodable, Sendable {
let visibility: Visibility
let tags: [String]
let updated: Date
+
+ private enum CodingKeys: String, CodingKey {
+ case rid
+ case name
+ case description
+ case website
+ case visibility
+ case tags
+ case updated
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ website = try container.decodeIfPresent(String.self, forKey: .website)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+ }
}
private struct ProjectDetailResponse: Decodable, Sendable {
@@ -38,11 +70,40 @@ private struct ProjectDetailPayload: Decodable, Sendable {
let mailingLists: ProjectMailingListPage
let sources: ProjectSourcePage
let trackers: ProjectTrackerPage
+
+ private enum CodingKeys: String, CodingKey {
+ case rid
+ case name
+ case description
+ case website
+ case visibility
+ case tags
+ case updated
+ case mailingLists
+ case sources
+ case trackers
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ website = try container.decodeIfPresent(String.self, forKey: .website)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+ tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+ updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+ mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
+ sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
+ trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
+ }
}
private struct ProjectMailingListPage: Decodable, Sendable {
let results: [ProjectMailingListPayload]
let cursor: String?
+
+ static let empty = ProjectMailingListPage(results: [], cursor: nil)
}
private struct ProjectMailingListPayload: Decodable, Sendable {
@@ -51,11 +112,30 @@ private struct ProjectMailingListPayload: Decodable, Sendable {
let description: String?
let visibility: Visibility
let owner: Entity
+
+ private enum CodingKeys: String, CodingKey {
+ case rid
+ case name
+ case description
+ case visibility
+ case owner
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+ owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+ }
}
private struct ProjectSourcePage: Decodable, Sendable {
let results: [ProjectSourcePayload]
let cursor: String?
+
+ static let empty = ProjectSourcePage(results: [], cursor: nil)
}
private struct ProjectSourcePayload: Decodable, Sendable {
@@ -65,11 +145,32 @@ private struct ProjectSourcePayload: Decodable, Sendable {
let visibility: Visibility
let owner: Entity
let repoType: Project.SourceRepo.RepoType
+
+ private enum CodingKeys: String, CodingKey {
+ case rid
+ case name
+ case description
+ case visibility
+ case owner
+ case repoType
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+ owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+ repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
+ }
}
private struct ProjectTrackerPage: Decodable, Sendable {
let results: [ProjectTrackerPayload]
let cursor: String?
+
+ static let empty = ProjectTrackerPage(results: [], cursor: nil)
}
private struct ProjectTrackerPayload: Decodable, Sendable {
@@ -78,6 +179,23 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
let description: String?
let visibility: Visibility
let owner: Entity
+
+ private enum CodingKeys: String, CodingKey {
+ case rid
+ case name
+ case description
+ case visibility
+ case owner
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ rid = try container.decode(String.self, forKey: .rid)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ description = try container.decodeIfPresent(String.self, forKey: .description)
+ visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+ owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+ }
}
struct ProjectService: Sendable {
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 42a6737..426d351 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -179,7 +179,7 @@ struct HomeView: View {
@ViewBuilder
private func projectsSection(_ viewModel: HomeViewModel) -> some View {
- if !viewModel.pinnedProjects.isEmpty {
+ if viewModel.hasPinnedProjects {
HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
NavigationLink {
ProjectsListView()
@@ -189,11 +189,27 @@ struct HomeView: View {
}
.buttonStyle(.plain)
} content: {
- ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
- NavigationLink {
- ProjectDetailView(project: project)
- } label: {
- HomeProjectRow(project: project)
+ if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
+ HomeSectionLoadingRow(label: "Loading pinned projects")
+ } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load pinned projects.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.pinnedProjects.isEmpty {
+ HomeSectionMessageRow(
+ text: "Pinned projects will appear here when they’re available.",
+ systemImage: "pin"
+ )
+ } else {
+ ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ HomeProjectRow(project: project)
+ }
}
}
}
@@ -388,26 +404,34 @@ private struct HomeProjectRow: View {
let project: Project
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(project.name)
- .font(.subheadline.weight(.medium))
- .lineLimit(1)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.displayName)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
- if let description = project.description, !description.isEmpty {
- Text(description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
+ if let description = project.displayDescription {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
- if let summary = project.resourceSummary {
- Text(summary)
- .font(.caption)
- .foregroundStyle(.tertiary)
- .lineLimit(1)
+ Spacer(minLength: 8)
+
+ VisibilityBadge(visibility: project.visibility)
}
+
+ Text(project.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
}
- .padding(.vertical, 2)
+ .contentShape(Rectangle())
+ .padding(.vertical, 4)
}
}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index e9f0f50..682c94d 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -185,6 +185,7 @@ final class HomeViewModel {
private(set) var isLoadingProjects = false
private(set) var isLoadingAssignedTickets = false
private(set) var isLoadingRecentBuilds = false
+ private(set) var projectsError: String?
private(set) var assignedTicketsError: String?
private(set) var recentBuildsError: String?
@@ -332,6 +333,7 @@ final class HomeViewModel {
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
isLoadingSystemStatus = true
+ projectsError = nil
assignedTicketsError = nil
recentBuildsError = nil
isShowingStaleSystemStatus = false
@@ -347,8 +349,9 @@ final class HomeViewModel {
switch projectsResult {
case .success(let projects):
self.projects = projects
- case .failure:
- self.projects = []
+ self.projectsError = nil
+ case .failure(let error):
+ self.projectsError = error.userFacingMessage
}
isLoadingProjects = false
@@ -407,6 +410,10 @@ final class HomeViewModel {
return pinnedIDs.compactMap { projectsByID[$0] }
}
+ var hasPinnedProjects: Bool {
+ !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
+ }
+
var failedBuildCount: Int {
recentBuilds.filter {
switch $0.job.status {
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index a04c526..11758ae 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -5,6 +5,7 @@ struct ProjectDetailView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
@State private var detailProject: Project?
@State private var isLoading = false
@State private var error: String?
@@ -27,13 +28,14 @@ struct ProjectDetailView: View {
var body: some View {
List {
headerSection
- linksSection
repositoriesSection
trackersSection
mailingListsSection
+ linksSection
+ emptyResourcesSection
}
.themedList()
- .navigationTitle(displayedProject.name)
+ .navigationTitle(displayedProject.displayName)
.navigationBarTitleDisplayMode(.inline)
.overlay {
if isLoading, detailProject == nil, !project.isFullyLoaded {
@@ -71,19 +73,19 @@ struct ProjectDetailView: View {
private var headerSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
- Text(displayedProject.name)
+ Text(displayedProject.displayName)
.font(.headline)
- if let description = displayedProject.description, !description.isEmpty {
+ if let description = displayedProject.displayDescription {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
- if !displayedProject.tags.isEmpty {
+ if !displayedProject.displayTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
- ForEach(displayedProject.tags, id: \.self) { tag in
+ ForEach(displayedProject.displayTags, id: \.self) { tag in
Text(tag)
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
@@ -94,7 +96,11 @@ struct ProjectDetailView: View {
}
}
+ LabeledContent("Project", value: displayedProject.visibility.displayName)
LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
+ if let summary = displayedProject.resourceSummary {
+ LabeledContent("Linked", value: summary)
+ }
}
.padding(.vertical, 4)
}
@@ -106,10 +112,20 @@ struct ProjectDetailView: View {
if !links.isEmpty {
Section("Links") {
ForEach(links) { link in
- Link(destination: link.url) {
- Label(link.title, systemImage: link.systemImage)
- .font(.subheadline)
+ Button {
+ openURL(link.url)
+ } label: {
+ HStack(spacing: 12) {
+ Label(link.title, systemImage: link.systemImage)
+ .font(.subheadline)
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
}
+ .buttonStyle(.plain)
}
}
}
@@ -122,14 +138,19 @@ struct ProjectDetailView: View {
ForEach(displayedProject.sources) { source in
Button {
Task {
- try? await appState.openProjectSource(source)
- dismiss()
+ do {
+ try await appState.openProjectSource(source)
+ dismiss()
+ } catch {
+ self.error = "Couldn’t open repository. \(error.userFacingMessage)"
+ }
}
} label: {
ProjectResourceRow(
- title: source.name,
- subtitle: source.owner.canonicalName,
- detail: source.description
+ title: source.displayName,
+ subtitle: source.ownerDisplayName,
+ detail: source.displayDescription,
+ systemImage: "book.closed"
)
}
.buttonStyle(.plain)
@@ -145,14 +166,19 @@ struct ProjectDetailView: View {
ForEach(displayedProject.trackers) { tracker in
Button {
Task {
- try? await appState.openProjectTracker(tracker)
- dismiss()
+ do {
+ try await appState.openProjectTracker(tracker)
+ dismiss()
+ } catch {
+ self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
+ }
}
} label: {
ProjectResourceRow(
- title: tracker.name,
- subtitle: tracker.owner.canonicalName,
- detail: tracker.description
+ title: tracker.displayName,
+ subtitle: tracker.ownerDisplayName,
+ detail: tracker.displayDescription,
+ systemImage: "checklist"
)
}
.buttonStyle(.plain)
@@ -171,9 +197,10 @@ struct ProjectDetailView: View {
dismiss()
} label: {
ProjectResourceRow(
- title: mailingList.name,
- subtitle: mailingList.owner.canonicalName,
- detail: mailingList.description
+ title: mailingList.displayName,
+ subtitle: mailingList.ownerDisplayName,
+ detail: mailingList.displayDescription,
+ systemImage: "list.bullet"
)
}
.buttonStyle(.plain)
@@ -182,6 +209,19 @@ struct ProjectDetailView: View {
}
}
+ @ViewBuilder
+ private var emptyResourcesSection: some View {
+ if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
+ Section {
+ ContentUnavailableView(
+ "No Linked Resources",
+ systemImage: "square.stack.3d.up.slash",
+ description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
+ )
+ }
+ }
+ }
+
private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
guard forceRefresh || !project.isFullyLoaded else {
detailProject = project
@@ -197,7 +237,7 @@ struct ProjectDetailView: View {
let service = ProjectService(client: appState.client)
detailProject = try await service.fetchProjectDetail(rid: project.id)
} catch {
- self.error = "Failed to load project"
+ self.error = "Couldn’t load project. \(error.userFacingMessage)"
}
}
@@ -210,31 +250,26 @@ struct ProjectDetailView: View {
private func projectLinks(for project: Project) -> [ProjectLink] {
var links: [ProjectLink] = []
- if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines),
- let url = URL(string: website),
- !website.isEmpty {
- links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url))
+ if let url = project.websiteURL {
+ links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
}
if let source = project.sources.first,
- let url = sourceURL(for: source) {
- links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url))
+ let url = source.webURL {
+ links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
}
if let tracker = project.trackers.first,
- let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) {
- links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url))
+ let url = tracker.webURL {
+ links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
}
- return links
- }
+ if let mailingList = project.mailingLists.first,
+ let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
+ links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
+ }
- private func sourceURL(for source: Project.SourceRepo) -> URL? {
- var components = URLComponents()
- components.scheme = "https"
- components.host = "\(source.repoType.service.rawValue).sr.ht"
- components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)"
- return components.url
+ return links
}
}
@@ -249,24 +284,39 @@ private struct ProjectResourceRow: View {
let title: String
let subtitle: String
let detail: String?
+ let systemImage: String
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.subheadline.weight(.medium))
-
- Text(subtitle)
- .font(.caption)
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: systemImage)
+ .frame(width: 18, alignment: .leading)
.foregroundStyle(.secondary)
- .lineLimit(1)
- if let detail, !detail.isEmpty {
- Text(detail)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.primary)
+
+ Text(subtitle)
.font(.caption)
- .foregroundStyle(.tertiary)
- .lineLimit(2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ if let detail, !detail.isEmpty {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
}
+
+ Spacer(minLength: 8)
+
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
}
- .padding(.vertical, 2)
+ .contentShape(Rectangle())
+ .padding(.vertical, 4)
}
}
diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift
index cd3d961..77a4172 100644
--- a/Hutch/Views/Projects/ProjectPinStore.swift
+++ b/Hutch/Views/Projects/ProjectPinStore.swift
@@ -6,7 +6,7 @@ enum ProjectPinStore {
defaults: UserDefaults = .standard
) -> [String] {
let pinnedProjects = loadAll(defaults: defaults)
- return pinnedProjects[userKey] ?? []
+ return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
}
static func isPinned(
@@ -23,12 +23,14 @@ enum ProjectPinStore {
defaults: UserDefaults = .standard
) {
var pinnedProjects = loadAll(defaults: defaults)
- var projectIDs = pinnedProjects[userKey] ?? []
+ var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
if let index = projectIDs.firstIndex(of: projectID) {
projectIDs.remove(at: index)
} else {
- projectIDs.append(projectID)
+ if let normalizedProjectID = normalizedProjectID(projectID) {
+ projectIDs.append(normalizedProjectID)
+ }
}
pinnedProjects[userKey] = projectIDs
@@ -40,11 +42,33 @@ enum ProjectPinStore {
return [:]
}
- return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+ let decoded = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+ return decoded.reduce(into: [String: [String]]()) { result, entry in
+ let normalizedUserKey = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedUserKey.isEmpty else { return }
+ let normalizedProjectIDs = normalizedProjectIDs(entry.value)
+ if !normalizedProjectIDs.isEmpty {
+ result[normalizedUserKey] = normalizedProjectIDs
+ }
+ }
}
private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
}
+
+ private static func normalizedProjectIDs(_ projectIDs: [String]) -> [String] {
+ var seen = Set<String>()
+ return projectIDs.compactMap { projectID in
+ guard let normalizedProjectID = normalizedProjectID(projectID) else { return nil }
+ guard seen.insert(normalizedProjectID).inserted else { return nil }
+ return normalizedProjectID
+ }
+ }
+
+ private static func normalizedProjectID(_ projectID: String) -> String? {
+ let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
}
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 0ffe183..5786462 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -34,7 +34,11 @@ final class ProjectsListViewModel {
do {
projects = try await service.fetchProjects()
} catch {
- self.error = "Failed to load projects"
+ if projects.isEmpty {
+ self.error = error.userFacingMessage
+ } else {
+ self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
+ }
}
}
}
@@ -72,6 +76,8 @@ struct ProjectsListView: View {
} label: {
ProjectListRow(project: project)
}
+ .buttonStyle(.plain)
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
}
}
.themedList()
@@ -96,7 +102,7 @@ struct ProjectsListView: View {
ContentUnavailableView(
"No Projects",
systemImage: "square.stack.3d.up",
- description: Text("Your SourceHut projects will appear here.")
+ description: Text("Projects from your SourceHut account will appear here when available.")
)
}
}
@@ -114,33 +120,49 @@ private struct ProjectListRow: View {
let project: Project
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(project.name)
- .font(.subheadline.weight(.medium))
- .lineLimit(1)
-
- Spacer()
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 10) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.displayName)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+
+ if let description = project.displayDescription {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
- Text(project.updated.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
- }
+ Spacer(minLength: 8)
- if let description = project.description, !description.isEmpty {
- Text(description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
+ VisibilityBadge(visibility: project.visibility)
}
- if let summary = project.resourceSummary {
- Text(summary)
- .font(.caption)
- .foregroundStyle(.tertiary)
- .lineLimit(1)
+ Text(project.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ if !project.displayTags.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ ForEach(project.displayTags.prefix(4), id: \.self) { tag in
+ Text(tag)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(.quaternary, in: Capsule())
+ }
+ }
+ }
+ .scrollDisabled(true)
}
}
- .padding(.vertical, 2)
+ .contentShape(Rectangle())
+ .padding(.vertical, 4)
}
}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index fffaba4..3e0c656 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -194,75 +194,67 @@ struct TicketListView: View {
List {
Section {
- VStack(alignment: .leading, spacing: 12) {
- Picker("Filter", selection: $vm.filter) {
- ForEach(TicketFilter.allCases, id: \.self) { filter in
- Text(filter.rawValue).tag(filter)
- }
- }
- .pickerStyle(.segmented)
-
- TicketQuickFilterBar(
- selectedLabels: viewModel.selectedLabels,
- savedFilters: viewModel.savedFilters,
- activeSavedFilterID: viewModel.activeSavedFilterID,
- canSaveCurrentFilter: viewModel.hasCustomFilterSelection
- ) {
- showLabelFilterSheet = true
- } onSaveFilter: {
- showSaveFilterSheet = true
- } onResetFilters: {
- vm.resetFilters()
- } onApplySavedFilter: { savedFilter in
- vm.applySavedFilter(savedFilter)
- } onDeleteSavedFilter: { savedFilter in
- vm.deleteSavedFilter(savedFilter)
- }
+ TicketListFilterHeader(
+ selectedLabels: viewModel.selectedLabels,
+ savedFilters: viewModel.savedFilters,
+ activeSavedFilterID: viewModel.activeSavedFilterID,
+ canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
+ filter: $vm.filter
+ ) {
+ showLabelFilterSheet = true
+ } onSaveFilter: {
+ showSaveFilterSheet = true
+ } onResetFilters: {
+ vm.resetFilters()
+ } onApplySavedFilter: { savedFilter in
+ vm.applySavedFilter(savedFilter)
+ } onDeleteSavedFilter: { savedFilter in
+ vm.deleteSavedFilter(savedFilter)
}
- .padding(.vertical, 4)
- .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
- }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
- // Tickets
- ForEach(viewModel.filteredTickets) { ticket in
- NavigationLink {
- TicketDetailView(
- ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
- trackerName: tracker.name,
- trackerId: tracker.id,
- trackerRid: tracker.rid,
- ticketId: ticket.id
- )
- } label: {
- TicketRowView(ticket: ticket)
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled {
- ticketAssignSwipeAction(ticket, viewModel: viewModel)
+ ForEach(viewModel.filteredTickets) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+ trackerName: tracker.name,
+ trackerId: tracker.id,
+ trackerRid: tracker.rid,
+ ticketId: ticket.id
+ )
+ } label: {
+ TicketRowView(ticket: ticket)
}
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- if swipeActionsEnabled {
- ticketStatusSwipeAction(ticket, viewModel: viewModel)
- ticketLabelSwipeAction(ticket, viewModel: viewModel)
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ if swipeActionsEnabled {
+ ticketAssignSwipeAction(ticket, viewModel: viewModel)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if swipeActionsEnabled {
+ ticketStatusSwipeAction(ticket, viewModel: viewModel)
+ ticketLabelSwipeAction(ticket, viewModel: viewModel)
+ }
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: ticket)
}
}
- .task {
- await viewModel.loadMoreIfNeeded(currentItem: ticket)
- }
- }
- if viewModel.isLoadingMore {
- HStack {
- Spacer()
- ProgressView()
- Spacer()
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
}
- .listRowSeparator(.hidden)
}
}
.listStyle(.plain)
+ .listSectionSpacing(.compact)
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
@@ -538,11 +530,12 @@ private struct TicketLabelsSheet: View {
}
}
-private struct TicketQuickFilterBar: View {
+private struct TicketListFilterHeader: View {
let selectedLabels: [TicketLabel]
let savedFilters: [SavedTicketFilter]
let activeSavedFilterID: SavedTicketFilter.ID?
let canSaveCurrentFilter: Bool
+ @Binding var filter: TicketFilter
let onShowLabels: () -> Void
let onSaveFilter: () -> Void
let onResetFilters: () -> Void
@@ -551,26 +544,45 @@ private struct TicketQuickFilterBar: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
- HStack(spacing: 8) {
- Button(action: onShowLabels) {
- Label(labelButtonTitle, systemImage: "tag")
- .font(.caption.weight(.medium))
+ Picker("Filter", selection: $filter) {
+ ForEach(TicketFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
}
- .buttonStyle(.bordered)
+ }
+ .pickerStyle(.segmented)
+
+ TicketQuickFilterBar(
+ selectedLabels: selectedLabels,
+ savedFilters: savedFilters,
+ activeSavedFilterID: activeSavedFilterID,
+ canSaveCurrentFilter: canSaveCurrentFilter,
+ onShowLabels: onShowLabels,
+ onSaveFilter: onSaveFilter,
+ onResetFilters: onResetFilters,
+ onApplySavedFilter: onApplySavedFilter,
+ onDeleteSavedFilter: onDeleteSavedFilter
+ )
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 6)
+ .padding(.bottom, 10)
+ }
+}
- Button(action: onSaveFilter) {
- Label("Save Filter", systemImage: "square.and.arrow.down")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.bordered)
- .disabled(!canSaveCurrentFilter)
+private struct TicketQuickFilterBar: View {
+ let selectedLabels: [TicketLabel]
+ let savedFilters: [SavedTicketFilter]
+ let activeSavedFilterID: SavedTicketFilter.ID?
+ let canSaveCurrentFilter: Bool
+ let onShowLabels: () -> Void
+ let onSaveFilter: () -> Void
+ let onResetFilters: () -> Void
+ let onApplySavedFilter: (SavedTicketFilter) -> Void
+ let onDeleteSavedFilter: (SavedTicketFilter) -> Void
- if canSaveCurrentFilter {
- Button("Reset", action: onResetFilters)
- .font(.caption.weight(.medium))
- .buttonStyle(.bordered)
- }
- }
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ actionButtons
if !selectedLabels.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
@@ -623,6 +635,51 @@ private struct TicketQuickFilterBar: View {
private var labelButtonTitle: String {
selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
}
+
+ @ViewBuilder
+ private var actionButtons: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) {
+ labelsButton
+ saveFilterButton
+ if canSaveCurrentFilter {
+ resetButton
+ }
+ Spacer(minLength: 0)
+ }
+
+ FlowLayout(spacing: 8) {
+ labelsButton
+ saveFilterButton
+ if canSaveCurrentFilter {
+ resetButton
+ }
+ }
+ }
+ }
+
+ private var labelsButton: some View {
+ Button(action: onShowLabels) {
+ Label(labelButtonTitle, systemImage: "tag")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.bordered)
+ }
+
+ private var saveFilterButton: some View {
+ Button(action: onSaveFilter) {
+ Label("Save Filter", systemImage: "square.and.arrow.down")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.bordered)
+ .disabled(!canSaveCurrentFilter)
+ }
+
+ private var resetButton: some View {
+ Button("Reset", action: onResetFilters)
+ .font(.caption.weight(.medium))
+ .buttonStyle(.bordered)
+ }
}
private struct TicketFilterLabelsSheet: View {
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
index a4ee0f7..17b4397 100644
--- a/HutchTests/ProjectPinStoreTests.swift
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -25,4 +25,17 @@ struct ProjectPinStoreTests {
#expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
}
+
+ @Test
+ func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
+ let defaults = UserDefaults(suiteName: #function)!
+ defaults.removePersistentDomain(forName: #function)
+
+ let encoded = try! JSONEncoder().encode([
+ "~alice": [" project-1 ", "", "project-1", "project-2"]
+ ])
+ defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
+
+ #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1", "project-2"])
+ }
}
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
index b47356e..eff800f 100644
--- a/HutchTests/ProjectTests.swift
+++ b/HutchTests/ProjectTests.swift
@@ -71,4 +71,25 @@ struct ProjectTests {
#expect(project.resourceSummary == "Website linked")
}
+
+ @Test
+ func displayHelpersNormalizeBlankValues() {
+ let project = Project(
+ id: "project-1",
+ name: " ",
+ description: "\n",
+ website: "https://example.com",
+ visibility: .unlisted,
+ tags: [" docs ", "", "Docs", "ios"],
+ updated: Date(timeIntervalSince1970: 0),
+ mailingLists: [],
+ sources: [],
+ trackers: []
+ )
+
+ #expect(project.displayName == "Untitled Project")
+ #expect(project.displayDescription == nil)
+ #expect(project.displayTags == ["docs", "ios"])
+ #expect(project.metadataLine.contains("Unlisted"))
+ }
}