diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 19:58:20 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 19:58:20 -0500 |
| commit | 9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a (patch) | |
| tree | bf7a249bec3b9aba18176e6139d690c9bf384ab5 /Hutch | |
| parent | 1354f2464180ece6d13296a543a964ddf6d2d393 (diff) | |
| download | hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.tar.gz hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.tar.bz2 hutch-9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a.zip | |
feat: add projects list, detail view, and home pinning
Implements: https://todo.sr.ht/~ccleberg/hutch/26
Implements: https://todo.sr.ht/~ccleberg/hutch/27
Implements: https://todo.sr.ht/~ccleberg/hutch/28
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 1 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Models/Project.swift | 27 | ||||
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 48 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 38 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 165 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectPinStore.swift | 50 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 146 |
11 files changed, 431 insertions, 67 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index eaf54f0..9a97657 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -9,6 +9,7 @@ enum AppStorageKeys { static let systemStatusSnapshotCache = "systemStatusSnapshotCache" static let systemStatusIncidentCache = "systemStatusIncidentCache" static let homeProjectsExpanded = "homeProjectsExpanded" + static let pinnedHomeProjects = "pinnedHomeProjects" static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" static let homeBuildsExpanded = "homeBuildsExpanded" static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index baca7a7..9e91a71 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -302,6 +302,7 @@ enum MoreDestination: Hashable { enum MoreRoute: Hashable { case lookup + case projects case lists case pastes case profile @@ -320,6 +321,8 @@ private struct MoreNavigationRoot: View { switch route { case .lookup: LookupView() + case .projects: + ProjectsListView() case .lists: MailingListListView() case .pastes: diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift index 2cd8e00..54c0318 100644 --- a/Hutch/Models/Project.swift +++ b/Hutch/Models/Project.swift @@ -69,6 +69,33 @@ struct Project: Identifiable, Hashable, Sendable { let mailingLists: [MailingList] let sources: [SourceRepo] let trackers: [Tracker] + let isFullyLoaded: Bool + + init( + id: String, + name: String, + description: String?, + website: String?, + visibility: Visibility, + tags: [String], + updated: Date, + mailingLists: [MailingList], + sources: [SourceRepo], + trackers: [Tracker], + isFullyLoaded: Bool = true + ) { + self.id = id + self.name = name + self.description = description + self.website = website + self.visibility = visibility + self.tags = tags + self.updated = updated + self.mailingLists = mailingLists + self.sources = sources + self.trackers = trackers + self.isFullyLoaded = isFullyLoaded + } var resourceSummary: String? { let parts = [ diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift index a76d1d7..5a0b466 100644 --- a/Hutch/Networking/ProjectService.swift +++ b/Hutch/Networking/ProjectService.swift @@ -34,6 +34,7 @@ private struct ProjectDetailPayload: Decodable, Sendable { let website: String? let visibility: Visibility let tags: [String] + let updated: Date let mailingLists: ProjectMailingListPage let sources: ProjectSourcePage let trackers: ProjectTrackerPage @@ -110,6 +111,7 @@ struct ProjectService: Sendable { website visibility tags + updated mailingLists(cursor: $mailingListsCursor) { results { rid @@ -150,22 +152,11 @@ struct ProjectService: Sendable { } func fetchProjects() async throws -> [Project] { - let summaries = try await fetchProjectSummaries() - guard !summaries.isEmpty else { return [] } - - return try await withThrowingTaskGroup(of: Project.self) { group in - for summary in summaries { - group.addTask { - try await self.fetchProjectDetail(summary: summary) - } - } + try await fetchProjectSummaries().map(Self.makeSummaryProject) + } - var projects: [Project] = [] - for try await project in group { - projects.append(project) - } - return projects.sorted { $0.updated > $1.updated } - } + func fetchProjectDetail(rid: String) async throws -> Project { + try await fetchProjectDetailPayload(rid: rid) } private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { @@ -192,10 +183,10 @@ struct ProjectService: Sendable { cursor = nextCursor } - return results + return results.sorted { $0.updated > $1.updated } } - private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project { + private func fetchProjectDetailPayload(rid: String) async throws -> Project { var mailingLists: [Project.MailingList] = [] var sources: [Project.SourceRepo] = [] var trackers: [Project.Tracker] = [] @@ -204,7 +195,7 @@ struct ProjectService: Sendable { var trackersCursor: String? while true { - var variables: [String: any Sendable] = ["rid": summary.rid] + var variables: [String: any Sendable] = ["rid": rid] if let mailingListsCursor { variables["mailingListsCursor"] = mailingListsCursor } @@ -269,15 +260,32 @@ struct ProjectService: Sendable { website: project.website, visibility: project.visibility, tags: project.tags, - updated: summary.updated, + updated: project.updated, mailingLists: deduplicate(mailingLists), sources: deduplicate(sources), - trackers: deduplicate(trackers) + trackers: deduplicate(trackers), + isFullyLoaded: true ) } } } + private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project { + Project( + id: summary.rid, + name: summary.name, + description: summary.description, + website: summary.website, + visibility: summary.visibility, + tags: summary.tags, + updated: summary.updated, + mailingLists: [], + sources: [], + trackers: [], + isFullyLoaded: false + ) + } + private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { var seen = Set<T.ID>() return items.filter { item in diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index bbae296..da58587 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -69,11 +69,11 @@ struct HomeView: View { .listStyle(.insetGrouped) .overlay { if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && viewModel.unreadInboxThreads.isEmpty { SRHTLoadingStateView(message: "Loading Home…") } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && - viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && viewModel.unreadInboxThreads.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( @@ -179,17 +179,17 @@ struct HomeView: View { @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { - if !viewModel.projects.isEmpty { - HomeSectionView("Projects", isExpanded: $projectsExpanded) { + if !viewModel.pinnedProjects.isEmpty { + HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { NavigationLink { - HomeProjectsListView(viewModel: viewModel) + ProjectsListView() } label: { Text("See All") .font(.caption.weight(.medium)) } .buttonStyle(.plain) } content: { - ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in + ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in NavigationLink { ProjectDetailView(project: project) } label: { @@ -411,32 +411,6 @@ private struct HomeProjectRow: View { } } -private struct HomeProjectsListView: View { - let viewModel: HomeViewModel - - var body: some View { - List { - ForEach(viewModel.projects) { project in - NavigationLink { - ProjectDetailView(project: project) - } label: { - HomeProjectRow(project: project) - } - } - } - .navigationTitle("Projects") - .navigationBarTitleDisplayMode(.inline) - .refreshable { - await viewModel.loadDashboard() - } - .overlay { - if viewModel.isLoadingProjects && viewModel.projects.isEmpty { - SRHTLoadingStateView(message: "Loading projects…") - } - } - } -} - private struct HomeBuildRow: View { @Environment(AppState.self) private var appState let build: HomeBuildItem diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index ec2b324..e9f0f50 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -195,6 +195,10 @@ final class HomeViewModel { private let ticketFetchConcurrencyLimit = 6 private let inboxUnreadConcurrencyLimit = 4 + private var currentUserKey: String { + currentUser.canonicalName + } + private static let jobsQuery = """ query jobs { jobs { @@ -392,7 +396,15 @@ final class HomeViewModel { } var hasDashboardContent: Bool { - !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil + !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil + } + + var pinnedProjects: [Project] { + let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey) + guard !pinnedIDs.isEmpty else { return [] } + + let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) + return pinnedIDs.compactMap { projectsByID[$0] } } var failedBuildCount: Int { diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index bfb532f..6cefcf1 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -422,6 +422,8 @@ struct LookupView: View { switch route { case .lookup: LookupView() + case .projects: + ProjectsListView() case .lists: MailingListListView() case .pastes: diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index caec3d5..8be25ca 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -19,6 +19,10 @@ struct MoreView: View { } Section("Other Services") { + NavigationLink(value: MoreRoute.projects) { + Label("Projects", systemImage: "square.stack.3d.up") + } + NavigationLink(value: MoreRoute.lists) { Label("Mailing Lists", systemImage: "list.bullet.rectangle") } diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index 89601a9..a04c526 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -2,49 +2,124 @@ import SwiftUI struct ProjectDetailView: View { let project: Project + @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @State private var detailProject: Project? + @State private var isLoading = false + @State private var error: String? + @State private var pinChangeCount = 0 + + private var displayedProject: Project { + detailProject ?? project + } + + private var currentUserKey: String? { + appState.currentUser?.canonicalName + } + + private var isPinnedToHome: Bool { + _ = pinChangeCount + guard let currentUserKey else { return false } + return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey) + } var body: some View { List { headerSection + linksSection repositoriesSection trackersSection mailingListsSection } - .navigationTitle(project.name) + .themedList() + .navigationTitle(displayedProject.name) .navigationBarTitleDisplayMode(.inline) + .overlay { + if isLoading, detailProject == nil, !project.isFullyLoaded { + SRHTLoadingStateView(message: "Loading project…") + } else if let error, detailProject == nil, !project.isFullyLoaded { + SRHTErrorStateView( + title: "Couldn't Load Project", + message: error, + retryAction: { await loadProjectIfNeeded(forceRefresh: true) } + ) + } + } + .toolbar { + if currentUserKey != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + togglePinnedState() + } label: { + Image(systemName: isPinnedToHome ? "pin.fill" : "pin") + } + .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") + } + } + } + .task { + await loadProjectIfNeeded() + } + .refreshable { + await loadProjectIfNeeded(forceRefresh: true) + } + .srhtErrorBanner(error: $error) } @ViewBuilder private var headerSection: some View { Section { - VStack(alignment: .leading, spacing: 8) { - Text(project.name) + VStack(alignment: .leading, spacing: 10) { + Text(displayedProject.name) .font(.headline) - if let description = project.description, !description.isEmpty { + if let description = displayedProject.description, !description.isEmpty { Text(description) .font(.subheadline) .foregroundStyle(.secondary) } - if let website = project.website, let url = URL(string: website) { - Link(destination: url) { - Label(website, systemImage: "link") - .font(.subheadline) + if !displayedProject.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(displayedProject.tags, id: \.self) { tag in + Text(tag) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + } } } + + LabeledContent("Updated", value: displayedProject.updated.relativeDescription) } .padding(.vertical, 4) } } @ViewBuilder + private var linksSection: some View { + let links = projectLinks(for: displayedProject) + if !links.isEmpty { + Section("Links") { + ForEach(links) { link in + Link(destination: link.url) { + Label(link.title, systemImage: link.systemImage) + .font(.subheadline) + } + } + } + } + } + + @ViewBuilder private var repositoriesSection: some View { - if !project.sources.isEmpty { + if !displayedProject.sources.isEmpty { Section("Repositories") { - ForEach(project.sources) { source in + ForEach(displayedProject.sources) { source in Button { Task { try? await appState.openProjectSource(source) @@ -65,9 +140,9 @@ struct ProjectDetailView: View { @ViewBuilder private var trackersSection: some View { - if !project.trackers.isEmpty { + if !displayedProject.trackers.isEmpty { Section("Trackers") { - ForEach(project.trackers) { tracker in + ForEach(displayedProject.trackers) { tracker in Button { Task { try? await appState.openProjectTracker(tracker) @@ -88,9 +163,9 @@ struct ProjectDetailView: View { @ViewBuilder private var mailingListsSection: some View { - if !project.mailingLists.isEmpty { + if !displayedProject.mailingLists.isEmpty { Section("Mailing Lists") { - ForEach(project.mailingLists) { mailingList in + ForEach(displayedProject.mailingLists) { mailingList in Button { appState.openMailingList(mailingList.inboxReference) dismiss() @@ -106,6 +181,68 @@ struct ProjectDetailView: View { } } } + + private func loadProjectIfNeeded(forceRefresh: Bool = false) async { + guard forceRefresh || !project.isFullyLoaded else { + detailProject = project + return + } + guard !isLoading else { return } + + isLoading = true + error = nil + defer { isLoading = false } + + do { + let service = ProjectService(client: appState.client) + detailProject = try await service.fetchProjectDetail(rid: project.id) + } catch { + self.error = "Failed to load project" + } + } + + private func togglePinnedState() { + guard let currentUserKey else { return } + ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey) + pinChangeCount += 1 + } + + 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 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)) + } + + 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)) + } + + return links + } + + 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 + } +} + +private struct ProjectLink: Identifiable { + let id: String + let title: String + let systemImage: String + let url: URL } private struct ProjectResourceRow: View { diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift new file mode 100644 index 0000000..cd3d961 --- /dev/null +++ b/Hutch/Views/Projects/ProjectPinStore.swift @@ -0,0 +1,50 @@ +import Foundation + +enum ProjectPinStore { + static func loadPinnedProjectIDs( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [String] { + let pinnedProjects = loadAll(defaults: defaults) + return pinnedProjects[userKey] ?? [] + } + + static func isPinned( + projectID: String, + for userKey: String, + defaults: UserDefaults = .standard + ) -> Bool { + loadPinnedProjectIDs(for: userKey, defaults: defaults).contains(projectID) + } + + static func togglePin( + projectID: String, + for userKey: String, + defaults: UserDefaults = .standard + ) { + var pinnedProjects = loadAll(defaults: defaults) + var projectIDs = pinnedProjects[userKey] ?? [] + + if let index = projectIDs.firstIndex(of: projectID) { + projectIDs.remove(at: index) + } else { + projectIDs.append(projectID) + } + + pinnedProjects[userKey] = projectIDs + save(pinnedProjects, defaults: defaults) + } + + private static func loadAll(defaults: UserDefaults) -> [String: [String]] { + guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { + return [:] + } + + return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] + } + + private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(pinnedProjects) else { return } + defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects) + } +} diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift new file mode 100644 index 0000000..0ffe183 --- /dev/null +++ b/Hutch/Views/Projects/ProjectsListView.swift @@ -0,0 +1,146 @@ +import SwiftUI + +@Observable +@MainActor +final class ProjectsListViewModel { + private(set) var projects: [Project] = [] + private(set) var isLoading = false + var error: String? + var searchText = "" + + private let service: ProjectService + + init(service: ProjectService) { + self.service = service + } + + var filteredProjects: [Project] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return projects } + + return projects.filter { + $0.name.lowercased().contains(query) || + ($0.description?.lowercased().contains(query) ?? false) || + $0.tags.contains(where: { $0.lowercased().contains(query) }) + } + } + + func loadProjects() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + projects = try await service.fetchProjects() + } catch { + self.error = "Failed to load projects" + } + } +} + +struct ProjectsListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: ProjectsListViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading projects…") + } + } + .navigationTitle("Projects") + .task { + if viewModel == nil { + let vm = ProjectsListViewModel(service: ProjectService(client: appState.client)) + viewModel = vm + await vm.loadProjects() + } + } + } + + @ViewBuilder + private func content(_ viewModel: ProjectsListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.filteredProjects) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + ProjectListRow(project: project) + } + } + } + .themedList() + .listStyle(.plain) + .searchable( + text: $vm.searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search projects" + ) + .overlay { + if viewModel.isLoading, viewModel.projects.isEmpty { + SRHTLoadingStateView(message: "Loading projects…") + } else if let error = viewModel.error, viewModel.projects.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Projects", + message: error, + retryAction: { await viewModel.loadProjects() } + ) + } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty { + ContentUnavailableView.search(text: viewModel.searchText) + } else if viewModel.projects.isEmpty { + ContentUnavailableView( + "No Projects", + systemImage: "square.stack.3d.up", + description: Text("Your SourceHut projects will appear here.") + ) + } + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadProjects() + } + .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) { + await viewModel.loadProjects() + } + } +} + +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() + + Text(project.updated.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if let description = project.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let summary = project.resourceSummary { + Text(summary) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .padding(.vertical, 2) + } +} |
