Commit 1924d6476d
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 39; | 518 | CURRENT_PROJECT_VERSION = 40; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 520 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | "$(inherited)", | 532 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 533 | "@executable_path/Frameworks", |
| 534 | ); | 534 | ); |
| 535 | MARKETING_VERSION = 2.17.0; | 535 | MARKETING_VERSION = 2.17.1; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 39; | 555 | CURRENT_PROJECT_VERSION = 40; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 557 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | |||
| 569 | "$(inherited)", | 569 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 570 | "@executable_path/Frameworks", |
| 571 | ); | 571 | ); |
| 572 | MARKETING_VERSION = 2.17.0; | 572 | MARKETING_VERSION = 2.17.1; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 39; | 635 | CURRENT_PROJECT_VERSION = 40; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 642 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 643 | "@executable_path/../../Frameworks", |
| 644 | ); | 644 | ); |
| 645 | MARKETING_VERSION = 2.17.0; | 645 | MARKETING_VERSION = 2.17.1; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 39; | 664 | CURRENT_PROJECT_VERSION = 40; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 671 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 672 | "@executable_path/../../Frameworks", |
| 673 | ); | 673 | ); |
| 674 | MARKETING_VERSION = 2.17.0; | 674 | MARKETING_VERSION = 2.17.1; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +1
| @@ -9,6 +9,7 @@ enum AppStorageKeys { | |||
| 9 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" | 9 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" |
| 10 | static let systemStatusIncidentCache = "systemStatusIncidentCache" | 10 | static let systemStatusIncidentCache = "systemStatusIncidentCache" |
| 11 | static let homeProjectsExpanded = "homeProjectsExpanded" | 11 | static let homeProjectsExpanded = "homeProjectsExpanded" |
| 12 | static let pinnedHomeProjects = "pinnedHomeProjects" | ||
| 12 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" | 13 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" |
| 13 | static let homeBuildsExpanded = "homeBuildsExpanded" | 14 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 14 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" | 15 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" |
Hutch/App/RootView.swift +3
| @@ -302,6 +302,7 @@ enum MoreDestination: Hashable { | |||
| 302 | 302 | ||
| 303 | enum MoreRoute: Hashable { | 303 | enum MoreRoute: Hashable { |
| 304 | case lookup | 304 | case lookup |
| 305 | case projects | ||
| 305 | case lists | 306 | case lists |
| 306 | case pastes | 307 | case pastes |
| 307 | case profile | 308 | case profile |
| @@ -320,6 +321,8 @@ private struct MoreNavigationRoot: View { | |||
| 320 | switch route { | 321 | switch route { |
| 321 | case .lookup: | 322 | case .lookup: |
| 322 | LookupView() | 323 | LookupView() |
| 324 | case .projects: | ||
| 325 | ProjectsListView() | ||
| 323 | case .lists: | 326 | case .lists: |
| 324 | MailingListListView() | 327 | MailingListListView() |
| 325 | case .pastes: | 328 | case .pastes: |
Hutch/Models/Project.swift +27
| @@ -69,6 +69,33 @@ struct Project: Identifiable, Hashable, Sendable { | |||
| 69 | let mailingLists: [MailingList] | 69 | let mailingLists: [MailingList] |
| 70 | let sources: [SourceRepo] | 70 | let sources: [SourceRepo] |
| 71 | let trackers: [Tracker] | 71 | let trackers: [Tracker] |
| 72 | let isFullyLoaded: Bool | ||
| 73 | |||
| 74 | init( | ||
| 75 | id: String, | ||
| 76 | name: String, | ||
| 77 | description: String?, | ||
| 78 | website: String?, | ||
| 79 | visibility: Visibility, | ||
| 80 | tags: [String], | ||
| 81 | updated: Date, | ||
| 82 | mailingLists: [MailingList], | ||
| 83 | sources: [SourceRepo], | ||
| 84 | trackers: [Tracker], | ||
| 85 | isFullyLoaded: Bool = true | ||
| 86 | ) { | ||
| 87 | self.id = id | ||
| 88 | self.name = name | ||
| 89 | self.description = description | ||
| 90 | self.website = website | ||
| 91 | self.visibility = visibility | ||
| 92 | self.tags = tags | ||
| 93 | self.updated = updated | ||
| 94 | self.mailingLists = mailingLists | ||
| 95 | self.sources = sources | ||
| 96 | self.trackers = trackers | ||
| 97 | self.isFullyLoaded = isFullyLoaded | ||
| 98 | } | ||
| 72 | 99 | ||
| 73 | var resourceSummary: String? { | 100 | var resourceSummary: String? { |
| 74 | let parts = [ | 101 | let parts = [ |
Hutch/Networking/ProjectService.swift +28 −20
| @@ -34,6 +34,7 @@ private struct ProjectDetailPayload: Decodable, Sendable { | |||
| 34 | let website: String? | 34 | let website: String? |
| 35 | let visibility: Visibility | 35 | let visibility: Visibility |
| 36 | let tags: [String] | 36 | let tags: [String] |
| 37 | let updated: Date | ||
| 37 | let mailingLists: ProjectMailingListPage | 38 | let mailingLists: ProjectMailingListPage |
| 38 | let sources: ProjectSourcePage | 39 | let sources: ProjectSourcePage |
| 39 | let trackers: ProjectTrackerPage | 40 | let trackers: ProjectTrackerPage |
| @@ -110,6 +111,7 @@ struct ProjectService: Sendable { | |||
| 110 | website | 111 | website |
| 111 | visibility | 112 | visibility |
| 112 | tags | 113 | tags |
| 114 | updated | ||
| 113 | mailingLists(cursor: $mailingListsCursor) { | 115 | mailingLists(cursor: $mailingListsCursor) { |
| 114 | results { | 116 | results { |
| 115 | rid | 117 | rid |
| @@ -150,22 +152,11 @@ struct ProjectService: Sendable { | |||
| 150 | } | 152 | } |
| 151 | 153 | ||
| 152 | func fetchProjects() async throws -> [Project] { | 154 | func fetchProjects() async throws -> [Project] { |
| 153 | let summaries = try await fetchProjectSummaries() | 155 | try await fetchProjectSummaries().map(Self.makeSummaryProject) |
| 154 | guard !summaries.isEmpty else { return [] } | 156 | } |
| 155 | |||
| 156 | return try await withThrowingTaskGroup(of: Project.self) { group in | ||
| 157 | for summary in summaries { | ||
| 158 | group.addTask { | ||
| 159 | try await self.fetchProjectDetail(summary: summary) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | 157 | ||
| 163 | var projects: [Project] = [] | 158 | func fetchProjectDetail(rid: String) async throws -> Project { |
| 164 | for try await project in group { | 159 | try await fetchProjectDetailPayload(rid: rid) |
| 165 | projects.append(project) | ||
| 166 | } | ||
| 167 | return projects.sorted { $0.updated > $1.updated } | ||
| 168 | } | ||
| 169 | } | 160 | } |
| 170 | 161 | ||
| 171 | private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { | 162 | private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { |
| @@ -192,10 +183,10 @@ struct ProjectService: Sendable { | |||
| 192 | cursor = nextCursor | 183 | cursor = nextCursor |
| 193 | } | 184 | } |
| 194 | 185 | ||
| 195 | return results | 186 | return results.sorted { $0.updated > $1.updated } |
| 196 | } | 187 | } |
| 197 | 188 | ||
| 198 | private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project { | 189 | private func fetchProjectDetailPayload(rid: String) async throws -> Project { |
| 199 | var mailingLists: [Project.MailingList] = [] | 190 | var mailingLists: [Project.MailingList] = [] |
| 200 | var sources: [Project.SourceRepo] = [] | 191 | var sources: [Project.SourceRepo] = [] |
| 201 | var trackers: [Project.Tracker] = [] | 192 | var trackers: [Project.Tracker] = [] |
| @@ -204,7 +195,7 @@ struct ProjectService: Sendable { | |||
| 204 | var trackersCursor: String? | 195 | var trackersCursor: String? |
| 205 | 196 | ||
| 206 | while true { | 197 | while true { |
| 207 | var variables: [String: any Sendable] = ["rid": summary.rid] | 198 | var variables: [String: any Sendable] = ["rid": rid] |
| 208 | if let mailingListsCursor { | 199 | if let mailingListsCursor { |
| 209 | variables["mailingListsCursor"] = mailingListsCursor | 200 | variables["mailingListsCursor"] = mailingListsCursor |
| 210 | } | 201 | } |
| @@ -269,15 +260,32 @@ struct ProjectService: Sendable { | |||
| 269 | website: project.website, | 260 | website: project.website, |
| 270 | visibility: project.visibility, | 261 | visibility: project.visibility, |
| 271 | tags: project.tags, | 262 | tags: project.tags, |
| 272 | updated: summary.updated, | 263 | updated: project.updated, |
| 273 | mailingLists: deduplicate(mailingLists), | 264 | mailingLists: deduplicate(mailingLists), |
| 274 | sources: deduplicate(sources), | 265 | sources: deduplicate(sources), |
| 275 | trackers: deduplicate(trackers) | 266 | trackers: deduplicate(trackers), |
| 267 | isFullyLoaded: true | ||
| 276 | ) | 268 | ) |
| 277 | } | 269 | } |
| 278 | } | 270 | } |
| 279 | } | 271 | } |
| 280 | 272 | ||
| 273 | private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project { | ||
| 274 | Project( | ||
| 275 | id: summary.rid, | ||
| 276 | name: summary.name, | ||
| 277 | description: summary.description, | ||
| 278 | website: summary.website, | ||
| 279 | visibility: summary.visibility, | ||
| 280 | tags: summary.tags, | ||
| 281 | updated: summary.updated, | ||
| 282 | mailingLists: [], | ||
| 283 | sources: [], | ||
| 284 | trackers: [], | ||
| 285 | isFullyLoaded: false | ||
| 286 | ) | ||
| 287 | } | ||
| 288 | |||
| 281 | private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { | 289 | private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable { |
| 282 | var seen = Set<T.ID>() | 290 | var seen = Set<T.ID>() |
| 283 | return items.filter { item in | 291 | return items.filter { item in |
Hutch/Views/Home/HomeView.swift +6 −32
| @@ -69,11 +69,11 @@ struct HomeView: View { | |||
| 69 | .listStyle(.insetGrouped) | 69 | .listStyle(.insetGrouped) |
| 70 | .overlay { | 70 | .overlay { |
| 71 | if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | 71 | if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && |
| 72 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | 72 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && |
| 73 | viewModel.unreadInboxThreads.isEmpty { | 73 | viewModel.unreadInboxThreads.isEmpty { |
| 74 | SRHTLoadingStateView(message: "Loading Home…") | 74 | SRHTLoadingStateView(message: "Loading Home…") |
| 75 | } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | 75 | } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && |
| 76 | viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | 76 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && |
| 77 | viewModel.unreadInboxThreads.isEmpty && | 77 | viewModel.unreadInboxThreads.isEmpty && |
| 78 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { | 78 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { |
| 79 | ContentUnavailableView( | 79 | ContentUnavailableView( |
| @@ -179,17 +179,17 @@ struct HomeView: View { | |||
| 179 | 179 | ||
| 180 | @ViewBuilder | 180 | @ViewBuilder |
| 181 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { | 181 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { |
| 182 | if !viewModel.projects.isEmpty { | 182 | if !viewModel.pinnedProjects.isEmpty { |
| 183 | HomeSectionView("Projects", isExpanded: $projectsExpanded) { | 183 | HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { |
| 184 | NavigationLink { | 184 | NavigationLink { |
| 185 | HomeProjectsListView(viewModel: viewModel) | 185 | ProjectsListView() |
| 186 | } label: { | 186 | } label: { |
| 187 | Text("See All") | 187 | Text("See All") |
| 188 | .font(.caption.weight(.medium)) | 188 | .font(.caption.weight(.medium)) |
| 189 | } | 189 | } |
| 190 | .buttonStyle(.plain) | 190 | .buttonStyle(.plain) |
| 191 | } content: { | 191 | } content: { |
| 192 | ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in | 192 | ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in |
| 193 | NavigationLink { | 193 | NavigationLink { |
| 194 | ProjectDetailView(project: project) | 194 | ProjectDetailView(project: project) |
| 195 | } label: { | 195 | } label: { |
| @@ -411,32 +411,6 @@ private struct HomeProjectRow: View { | |||
| 411 | } | 411 | } |
| 412 | } | 412 | } |
| 413 | 413 | ||
| 414 | private struct HomeProjectsListView: View { | ||
| 415 | let viewModel: HomeViewModel | ||
| 416 | |||
| 417 | var body: some View { | ||
| 418 | List { | ||
| 419 | ForEach(viewModel.projects) { project in | ||
| 420 | NavigationLink { | ||
| 421 | ProjectDetailView(project: project) | ||
| 422 | } label: { | ||
| 423 | HomeProjectRow(project: project) | ||
| 424 | } | ||
| 425 | } | ||
| 426 | } | ||
| 427 | .navigationTitle("Projects") | ||
| 428 | .navigationBarTitleDisplayMode(.inline) | ||
| 429 | .refreshable { | ||
| 430 | await viewModel.loadDashboard() | ||
| 431 | } | ||
| 432 | .overlay { | ||
| 433 | if viewModel.isLoadingProjects && viewModel.projects.isEmpty { | ||
| 434 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 435 | } | ||
| 436 | } | ||
| 437 | } | ||
| 438 | } | ||
| 439 | |||
| 440 | private struct HomeBuildRow: View { | 414 | private struct HomeBuildRow: View { |
| 441 | @Environment(AppState.self) private var appState | 415 | @Environment(AppState.self) private var appState |
| 442 | let build: HomeBuildItem | 416 | let build: HomeBuildItem |
Hutch/Views/Home/HomeViewModel.swift +13 −1
| @@ -195,6 +195,10 @@ final class HomeViewModel { | |||
| 195 | private let ticketFetchConcurrencyLimit = 6 | 195 | private let ticketFetchConcurrencyLimit = 6 |
| 196 | private let inboxUnreadConcurrencyLimit = 4 | 196 | private let inboxUnreadConcurrencyLimit = 4 |
| 197 | 197 | ||
| 198 | private var currentUserKey: String { | ||
| 199 | currentUser.canonicalName | ||
| 200 | } | ||
| 201 | |||
| 198 | private static let jobsQuery = """ | 202 | private static let jobsQuery = """ |
| 199 | query jobs { | 203 | query jobs { |
| 200 | jobs { | 204 | jobs { |
| @@ -392,7 +396,15 @@ final class HomeViewModel { | |||
| 392 | } | 396 | } |
| 393 | 397 | ||
| 394 | var hasDashboardContent: Bool { | 398 | var hasDashboardContent: Bool { |
| 395 | !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil | 399 | !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil |
| 400 | } | ||
| 401 | |||
| 402 | var pinnedProjects: [Project] { | ||
| 403 | let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey) | ||
| 404 | guard !pinnedIDs.isEmpty else { return [] } | ||
| 405 | |||
| 406 | let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) | ||
| 407 | return pinnedIDs.compactMap { projectsByID[$0] } | ||
| 396 | } | 408 | } |
| 397 | 409 | ||
| 398 | var failedBuildCount: Int { | 410 | var failedBuildCount: Int { |
Hutch/Views/Lookup/LookupView.swift +2
| @@ -422,6 +422,8 @@ struct LookupView: View { | |||
| 422 | switch route { | 422 | switch route { |
| 423 | case .lookup: | 423 | case .lookup: |
| 424 | LookupView() | 424 | LookupView() |
| 425 | case .projects: | ||
| 426 | ProjectsListView() | ||
| 425 | case .lists: | 427 | case .lists: |
| 426 | MailingListListView() | 428 | MailingListListView() |
| 427 | case .pastes: | 429 | case .pastes: |
Hutch/Views/More/MoreView.swift +4
| @@ -19,6 +19,10 @@ struct MoreView: View { | |||
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | Section("Other Services") { | 21 | Section("Other Services") { |
| 22 | NavigationLink(value: MoreRoute.projects) { | ||
| 23 | Label("Projects", systemImage: "square.stack.3d.up") | ||
| 24 | } | ||
| 25 | |||
| 22 | NavigationLink(value: MoreRoute.lists) { | 26 | NavigationLink(value: MoreRoute.lists) { |
| 23 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") | 27 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") |
| 24 | } | 28 | } |
Hutch/Views/Projects/ProjectDetailView.swift +151 −14
| @@ -2,49 +2,124 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct ProjectDetailView: View { | 3 | struct ProjectDetailView: View { |
| 4 | let project: Project | 4 | let project: Project |
| 5 | |||
| 5 | @Environment(AppState.self) private var appState | 6 | @Environment(AppState.self) private var appState |
| 6 | @Environment(\.dismiss) private var dismiss | 7 | @Environment(\.dismiss) private var dismiss |
| 8 | @State private var detailProject: Project? | ||
| 9 | @State private var isLoading = false | ||
| 10 | @State private var error: String? | ||
| 11 | @State private var pinChangeCount = 0 | ||
| 12 | |||
| 13 | private var displayedProject: Project { | ||
| 14 | detailProject ?? project | ||
| 15 | } | ||
| 16 | |||
| 17 | private var currentUserKey: String? { | ||
| 18 | appState.currentUser?.canonicalName | ||
| 19 | } | ||
| 20 | |||
| 21 | private var isPinnedToHome: Bool { | ||
| 22 | _ = pinChangeCount | ||
| 23 | guard let currentUserKey else { return false } | ||
| 24 | return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey) | ||
| 25 | } | ||
| 7 | 26 | ||
| 8 | var body: some View { | 27 | var body: some View { |
| 9 | List { | 28 | List { |
| 10 | headerSection | 29 | headerSection |
| 30 | linksSection | ||
| 11 | repositoriesSection | 31 | repositoriesSection |
| 12 | trackersSection | 32 | trackersSection |
| 13 | mailingListsSection | 33 | mailingListsSection |
| 14 | } | 34 | } |
| 15 | .navigationTitle(project.name) | 35 | .themedList() |
| 36 | .navigationTitle(displayedProject.name) | ||
| 16 | .navigationBarTitleDisplayMode(.inline) | 37 | .navigationBarTitleDisplayMode(.inline) |
| 38 | .overlay { | ||
| 39 | if isLoading, detailProject == nil, !project.isFullyLoaded { | ||
| 40 | SRHTLoadingStateView(message: "Loading project…") | ||
| 41 | } else if let error, detailProject == nil, !project.isFullyLoaded { | ||
| 42 | SRHTErrorStateView( | ||
| 43 | title: "Couldn't Load Project", | ||
| 44 | message: error, | ||
| 45 | retryAction: { await loadProjectIfNeeded(forceRefresh: true) } | ||
| 46 | ) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | .toolbar { | ||
| 50 | if currentUserKey != nil { | ||
| 51 | ToolbarItem(placement: .topBarTrailing) { | ||
| 52 | Button { | ||
| 53 | togglePinnedState() | ||
| 54 | } label: { | ||
| 55 | Image(systemName: isPinnedToHome ? "pin.fill" : "pin") | ||
| 56 | } | ||
| 57 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .task { | ||
| 62 | await loadProjectIfNeeded() | ||
| 63 | } | ||
| 64 | .refreshable { | ||
| 65 | await loadProjectIfNeeded(forceRefresh: true) | ||
| 66 | } | ||
| 67 | .srhtErrorBanner(error: $error) | ||
| 17 | } | 68 | } |
| 18 | 69 | ||
| 19 | @ViewBuilder | 70 | @ViewBuilder |
| 20 | private var headerSection: some View { | 71 | private var headerSection: some View { |
| 21 | Section { | 72 | Section { |
| 22 | VStack(alignment: .leading, spacing: 8) { | 73 | VStack(alignment: .leading, spacing: 10) { |
| 23 | Text(project.name) | 74 | Text(displayedProject.name) |
| 24 | .font(.headline) | 75 | .font(.headline) |
| 25 | 76 | ||
| 26 | if let description = project.description, !description.isEmpty { | 77 | if let description = displayedProject.description, !description.isEmpty { |
| 27 | Text(description) | 78 | Text(description) |
| 28 | .font(.subheadline) | 79 | .font(.subheadline) |
| 29 | .foregroundStyle(.secondary) | 80 | .foregroundStyle(.secondary) |
| 30 | } | 81 | } |
| 31 | 82 | ||
| 32 | if let website = project.website, let url = URL(string: website) { | 83 | if !displayedProject.tags.isEmpty { |
| 33 | Link(destination: url) { | 84 | ScrollView(.horizontal, showsIndicators: false) { |
| 34 | Label(website, systemImage: "link") | 85 | HStack(spacing: 8) { |
| 35 | .font(.subheadline) | 86 | ForEach(displayedProject.tags, id: \.self) { tag in |
| 87 | Text(tag) | ||
| 88 | .font(.caption.weight(.medium)) | ||
| 89 | .padding(.horizontal, 10) | ||
| 90 | .padding(.vertical, 4) | ||
| 91 | .background(.quaternary, in: Capsule()) | ||
| 92 | } | ||
| 93 | } | ||
| 36 | } | 94 | } |
| 37 | } | 95 | } |
| 96 | |||
| 97 | LabeledContent("Updated", value: displayedProject.updated.relativeDescription) | ||
| 38 | } | 98 | } |
| 39 | .padding(.vertical, 4) | 99 | .padding(.vertical, 4) |
| 40 | } | 100 | } |
| 41 | } | 101 | } |
| 42 | 102 | ||
| 103 | @ViewBuilder | ||
| 104 | private var linksSection: some View { | ||
| 105 | let links = projectLinks(for: displayedProject) | ||
| 106 | if !links.isEmpty { | ||
| 107 | Section("Links") { | ||
| 108 | ForEach(links) { link in | ||
| 109 | Link(destination: link.url) { | ||
| 110 | Label(link.title, systemImage: link.systemImage) | ||
| 111 | .font(.subheadline) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 43 | @ViewBuilder | 118 | @ViewBuilder |
| 44 | private var repositoriesSection: some View { | 119 | private var repositoriesSection: some View { |
| 45 | if !project.sources.isEmpty { | 120 | if !displayedProject.sources.isEmpty { |
| 46 | Section("Repositories") { | 121 | Section("Repositories") { |
| 47 | ForEach(project.sources) { source in | 122 | ForEach(displayedProject.sources) { source in |
| 48 | Button { | 123 | Button { |
| 49 | Task { | 124 | Task { |
| 50 | try? await appState.openProjectSource(source) | 125 | try? await appState.openProjectSource(source) |
| @@ -65,9 +140,9 @@ struct ProjectDetailView: View { | |||
| 65 | 140 | ||
| 66 | @ViewBuilder | 141 | @ViewBuilder |
| 67 | private var trackersSection: some View { | 142 | private var trackersSection: some View { |
| 68 | if !project.trackers.isEmpty { | 143 | if !displayedProject.trackers.isEmpty { |
| 69 | Section("Trackers") { | 144 | Section("Trackers") { |
| 70 | ForEach(project.trackers) { tracker in | 145 | ForEach(displayedProject.trackers) { tracker in |
| 71 | Button { | 146 | Button { |
| 72 | Task { | 147 | Task { |
| 73 | try? await appState.openProjectTracker(tracker) | 148 | try? await appState.openProjectTracker(tracker) |
| @@ -88,9 +163,9 @@ struct ProjectDetailView: View { | |||
| 88 | 163 | ||
| 89 | @ViewBuilder | 164 | @ViewBuilder |
| 90 | private var mailingListsSection: some View { | 165 | private var mailingListsSection: some View { |
| 91 | if !project.mailingLists.isEmpty { | 166 | if !displayedProject.mailingLists.isEmpty { |
| 92 | Section("Mailing Lists") { | 167 | Section("Mailing Lists") { |
| 93 | ForEach(project.mailingLists) { mailingList in | 168 | ForEach(displayedProject.mailingLists) { mailingList in |
| 94 | Button { | 169 | Button { |
| 95 | appState.openMailingList(mailingList.inboxReference) | 170 | appState.openMailingList(mailingList.inboxReference) |
| 96 | dismiss() | 171 | dismiss() |
| @@ -106,6 +181,68 @@ struct ProjectDetailView: View { | |||
| 106 | } | 181 | } |
| 107 | } | 182 | } |
| 108 | } | 183 | } |
| 184 | |||
| 185 | private func loadProjectIfNeeded(forceRefresh: Bool = false) async { | ||
| 186 | guard forceRefresh || !project.isFullyLoaded else { | ||
| 187 | detailProject = project | ||
| 188 | return | ||
| 189 | } | ||
| 190 | guard !isLoading else { return } | ||
| 191 | |||
| 192 | isLoading = true | ||
| 193 | error = nil | ||
| 194 | defer { isLoading = false } | ||
| 195 | |||
| 196 | do { | ||
| 197 | let service = ProjectService(client: appState.client) | ||
| 198 | detailProject = try await service.fetchProjectDetail(rid: project.id) | ||
| 199 | } catch { | ||
| 200 | self.error = "Failed to load project" | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | private func togglePinnedState() { | ||
| 205 | guard let currentUserKey else { return } | ||
| 206 | ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey) | ||
| 207 | pinChangeCount += 1 | ||
| 208 | } | ||
| 209 | |||
| 210 | private func projectLinks(for project: Project) -> [ProjectLink] { | ||
| 211 | var links: [ProjectLink] = [] | ||
| 212 | |||
| 213 | if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| 214 | let url = URL(string: website), | ||
| 215 | !website.isEmpty { | ||
| 216 | links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url)) | ||
| 217 | } | ||
| 218 | |||
| 219 | if let source = project.sources.first, | ||
| 220 | let url = sourceURL(for: source) { | ||
| 221 | links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url)) | ||
| 222 | } | ||
| 223 | |||
| 224 | if let tracker = project.trackers.first, | ||
| 225 | let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) { | ||
| 226 | links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url)) | ||
| 227 | } | ||
| 228 | |||
| 229 | return links | ||
| 230 | } | ||
| 231 | |||
| 232 | private func sourceURL(for source: Project.SourceRepo) -> URL? { | ||
| 233 | var components = URLComponents() | ||
| 234 | components.scheme = "https" | ||
| 235 | components.host = "\(source.repoType.service.rawValue).sr.ht" | ||
| 236 | components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)" | ||
| 237 | return components.url | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | private struct ProjectLink: Identifiable { | ||
| 242 | let id: String | ||
| 243 | let title: String | ||
| 244 | let systemImage: String | ||
| 245 | let url: URL | ||
| 109 | } | 246 | } |
| 110 | 247 | ||
| 111 | private struct ProjectResourceRow: View { | 248 | private struct ProjectResourceRow: View { |
Hutch/Views/Projects/ProjectPinStore.swift added +50
| @@ -0,0 +1,50 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum ProjectPinStore { | ||
| 4 | static func loadPinnedProjectIDs( | ||
| 5 | for userKey: String, | ||
| 6 | defaults: UserDefaults = .standard | ||
| 7 | ) -> [String] { | ||
| 8 | let pinnedProjects = loadAll(defaults: defaults) | ||
| 9 | return pinnedProjects[userKey] ?? [] | ||
| 10 | } | ||
| 11 | |||
| 12 | static func isPinned( | ||
| 13 | projectID: String, | ||
| 14 | for userKey: String, | ||
| 15 | defaults: UserDefaults = .standard | ||
| 16 | ) -> Bool { | ||
| 17 | loadPinnedProjectIDs(for: userKey, defaults: defaults).contains(projectID) | ||
| 18 | } | ||
| 19 | |||
| 20 | static func togglePin( | ||
| 21 | projectID: String, | ||
| 22 | for userKey: String, | ||
| 23 | defaults: UserDefaults = .standard | ||
| 24 | ) { | ||
| 25 | var pinnedProjects = loadAll(defaults: defaults) | ||
| 26 | var projectIDs = pinnedProjects[userKey] ?? [] | ||
| 27 | |||
| 28 | if let index = projectIDs.firstIndex(of: projectID) { | ||
| 29 | projectIDs.remove(at: index) | ||
| 30 | } else { | ||
| 31 | projectIDs.append(projectID) | ||
| 32 | } | ||
| 33 | |||
| 34 | pinnedProjects[userKey] = projectIDs | ||
| 35 | save(pinnedProjects, defaults: defaults) | ||
| 36 | } | ||
| 37 | |||
| 38 | private static func loadAll(defaults: UserDefaults) -> [String: [String]] { | ||
| 39 | guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { | ||
| 40 | return [:] | ||
| 41 | } | ||
| 42 | |||
| 43 | return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] | ||
| 44 | } | ||
| 45 | |||
| 46 | private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) { | ||
| 47 | guard let data = try? JSONEncoder().encode(pinnedProjects) else { return } | ||
| 48 | defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects) | ||
| 49 | } | ||
| 50 | } | ||
Hutch/Views/Projects/ProjectsListView.swift added +146
| @@ -0,0 +1,146 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class ProjectsListViewModel { | ||
| 6 | private(set) var projects: [Project] = [] | ||
| 7 | private(set) var isLoading = false | ||
| 8 | var error: String? | ||
| 9 | var searchText = "" | ||
| 10 | |||
| 11 | private let service: ProjectService | ||
| 12 | |||
| 13 | init(service: ProjectService) { | ||
| 14 | self.service = service | ||
| 15 | } | ||
| 16 | |||
| 17 | var filteredProjects: [Project] { | ||
| 18 | let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 19 | guard !query.isEmpty else { return projects } | ||
| 20 | |||
| 21 | return projects.filter { | ||
| 22 | $0.name.lowercased().contains(query) || | ||
| 23 | ($0.description?.lowercased().contains(query) ?? false) || | ||
| 24 | $0.tags.contains(where: { $0.lowercased().contains(query) }) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | func loadProjects() async { | ||
| 29 | guard !isLoading else { return } | ||
| 30 | isLoading = true | ||
| 31 | error = nil | ||
| 32 | defer { isLoading = false } | ||
| 33 | |||
| 34 | do { | ||
| 35 | projects = try await service.fetchProjects() | ||
| 36 | } catch { | ||
| 37 | self.error = "Failed to load projects" | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | struct ProjectsListView: View { | ||
| 43 | @Environment(AppState.self) private var appState | ||
| 44 | @State private var viewModel: ProjectsListViewModel? | ||
| 45 | |||
| 46 | var body: some View { | ||
| 47 | Group { | ||
| 48 | if let viewModel { | ||
| 49 | content(viewModel) | ||
| 50 | } else { | ||
| 51 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 52 | } | ||
| 53 | } | ||
| 54 | .navigationTitle("Projects") | ||
| 55 | .task { | ||
| 56 | if viewModel == nil { | ||
| 57 | let vm = ProjectsListViewModel(service: ProjectService(client: appState.client)) | ||
| 58 | viewModel = vm | ||
| 59 | await vm.loadProjects() | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | @ViewBuilder | ||
| 65 | private func content(_ viewModel: ProjectsListViewModel) -> some View { | ||
| 66 | @Bindable var vm = viewModel | ||
| 67 | |||
| 68 | List { | ||
| 69 | ForEach(viewModel.filteredProjects) { project in | ||
| 70 | NavigationLink { | ||
| 71 | ProjectDetailView(project: project) | ||
| 72 | } label: { | ||
| 73 | ProjectListRow(project: project) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | .themedList() | ||
| 78 | .listStyle(.plain) | ||
| 79 | .searchable( | ||
| 80 | text: $vm.searchText, | ||
| 81 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 82 | prompt: "Search projects" | ||
| 83 | ) | ||
| 84 | .overlay { | ||
| 85 | if viewModel.isLoading, viewModel.projects.isEmpty { | ||
| 86 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 87 | } else if let error = viewModel.error, viewModel.projects.isEmpty { | ||
| 88 | SRHTErrorStateView( | ||
| 89 | title: "Couldn't Load Projects", | ||
| 90 | message: error, | ||
| 91 | retryAction: { await viewModel.loadProjects() } | ||
| 92 | ) | ||
| 93 | } else if !viewModel.projects.isEmpty, viewModel.filteredProjects.isEmpty { | ||
| 94 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 95 | } else if viewModel.projects.isEmpty { | ||
| 96 | ContentUnavailableView( | ||
| 97 | "No Projects", | ||
| 98 | systemImage: "square.stack.3d.up", | ||
| 99 | description: Text("Your SourceHut projects will appear here.") | ||
| 100 | ) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | .srhtErrorBanner(error: $vm.error) | ||
| 104 | .refreshable { | ||
| 105 | await viewModel.loadProjects() | ||
| 106 | } | ||
| 107 | .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) { | ||
| 108 | await viewModel.loadProjects() | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private struct ProjectListRow: View { | ||
| 114 | let project: Project | ||
| 115 | |||
| 116 | var body: some View { | ||
| 117 | VStack(alignment: .leading, spacing: 4) { | ||
| 118 | HStack(alignment: .firstTextBaseline, spacing: 8) { | ||
| 119 | Text(project.name) | ||
| 120 | .font(.subheadline.weight(.medium)) | ||
| 121 | .lineLimit(1) | ||
| 122 | |||
| 123 | Spacer() | ||
| 124 | |||
| 125 | Text(project.updated.relativeDescription) | ||
| 126 | .font(.caption) | ||
| 127 | .foregroundStyle(.tertiary) | ||
| 128 | } | ||
| 129 | |||
| 130 | if let description = project.description, !description.isEmpty { | ||
| 131 | Text(description) | ||
| 132 | .font(.caption) | ||
| 133 | .foregroundStyle(.secondary) | ||
| 134 | .lineLimit(2) | ||
| 135 | } | ||
| 136 | |||
| 137 | if let summary = project.resourceSummary { | ||
| 138 | Text(summary) | ||
| 139 | .font(.caption) | ||
| 140 | .foregroundStyle(.tertiary) | ||
| 141 | .lineLimit(1) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | .padding(.vertical, 2) | ||
| 145 | } | ||
| 146 | } | ||
HutchTests/ProjectPinStoreTests.swift added +28
| @@ -0,0 +1,28 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct ProjectPinStoreTests { | ||
| 6 | @Test | ||
| 7 | func storesPinnedProjectsPerUser() { | ||
| 8 | let defaults = UserDefaults(suiteName: #function)! | ||
| 9 | defaults.removePersistentDomain(forName: #function) | ||
| 10 | |||
| 11 | ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults) | ||
| 12 | ProjectPinStore.togglePin(projectID: "project-2", for: "~bob", defaults: defaults) | ||
| 13 | |||
| 14 | #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1"]) | ||
| 15 | #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~bob", defaults: defaults) == ["project-2"]) | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test | ||
| 19 | func toggleRemovesExistingPin() { | ||
| 20 | let defaults = UserDefaults(suiteName: #function)! | ||
| 21 | defaults.removePersistentDomain(forName: #function) | ||
| 22 | |||
| 23 | ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults) | ||
| 24 | ProjectPinStore.togglePin(projectID: "project-1", for: "~alice", defaults: defaults) | ||
| 25 | |||
| 26 | #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty) | ||
| 27 | } | ||
| 28 | } | ||