diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 22:06:16 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 22:06:16 -0500 |
| commit | 86bcd9452d3107f68aca085e03e69b02667c0dbb (patch) | |
| tree | 8b0adece5cb0f8ffb178eaab2cd12d025c9ebf0a | |
| parent | 31cbca674957eea7ae2529012e20a9d95158e561 (diff) | |
| download | hutch-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.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Extensions/SRHTWebURL.swift | 16 | ||||
| -rw-r--r-- | Hutch/Models/Project.swift | 106 | ||||
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 118 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 68 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 152 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectPinStore.swift | 32 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 70 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 209 | ||||
| -rw-r--r-- | HutchTests/ProjectPinStoreTests.swift | 13 | ||||
| -rw-r--r-- | HutchTests/ProjectTests.swift | 21 |
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")) + } } |
