Commit 4c67bc570f
Unsigned
Layout: unified · split
Hutch/App/RootView.swift +13 −2
| @@ -6,13 +6,15 @@ struct RootView: View { | |||
| 6 | @Environment(AppState.self) private var appState | 6 | @Environment(AppState.self) private var appState |
| 7 | 7 | ||
| 8 | enum Tab: Hashable { | 8 | enum Tab: Hashable { |
| 9 | case home | ||
| 9 | case repositories | 10 | case repositories |
| 10 | case builds | 11 | case builds |
| 11 | case tickets | 12 | case tickets |
| 12 | case settings | 13 | case settings |
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | @State private var selectedTab: Tab = .repositories | 16 | @State private var selectedTab: Tab = .home |
| 17 | @State private var homePath = NavigationPath() | ||
| 16 | @State private var repoPath = NavigationPath() | 18 | @State private var repoPath = NavigationPath() |
| 17 | @State private var buildsPath = NavigationPath() | 19 | @State private var buildsPath = NavigationPath() |
| 18 | @State private var ticketsPath = NavigationPath() | 20 | @State private var ticketsPath = NavigationPath() |
| @@ -47,6 +49,14 @@ struct RootView: View { | |||
| 47 | 49 | ||
| 48 | private var tabContent: some View { | 50 | private var tabContent: some View { |
| 49 | TabView(selection: $selectedTab) { | 51 | TabView(selection: $selectedTab) { |
| 52 | NavigationStack(path: $homePath) { | ||
| 53 | HomeView() | ||
| 54 | } | ||
| 55 | .tag(Tab.home) | ||
| 56 | .tabItem { | ||
| 57 | Label("Home", systemImage: "house") | ||
| 58 | } | ||
| 59 | |||
| 50 | NavigationStack(path: $repoPath) { | 60 | NavigationStack(path: $repoPath) { |
| 51 | RepositoryListView() | 61 | RepositoryListView() |
| 52 | } | 62 | } |
| @@ -106,10 +116,11 @@ struct RootView: View { | |||
| 106 | case .launching: | 116 | case .launching: |
| 107 | break | 117 | break |
| 108 | case .unauthenticated: | 118 | case .unauthenticated: |
| 119 | homePath = NavigationPath() | ||
| 109 | repoPath = NavigationPath() | 120 | repoPath = NavigationPath() |
| 110 | buildsPath = NavigationPath() | 121 | buildsPath = NavigationPath() |
| 111 | ticketsPath = NavigationPath() | 122 | ticketsPath = NavigationPath() |
| 112 | selectedTab = .repositories | 123 | selectedTab = .home |
| 113 | isResolvingDeepLink = false | 124 | isResolvingDeepLink = false |
| 114 | case .authenticated: | 125 | case .authenticated: |
| 115 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) | 126 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) |
Hutch/Views/Home/HomeView.swift added +297
| @@ -0,0 +1,297 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct HomeView: View { | ||
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @State private var viewModel: HomeViewModel? | ||
| 6 | private let previewLimit = 4 | ||
| 7 | |||
| 8 | var body: some View { | ||
| 9 | Group { | ||
| 10 | if let viewModel { | ||
| 11 | content(viewModel) | ||
| 12 | } else { | ||
| 13 | SRHTLoadingStateView(message: "Loading Home…") | ||
| 14 | } | ||
| 15 | } | ||
| 16 | .navigationTitle("Home") | ||
| 17 | .task { | ||
| 18 | if viewModel == nil, let currentUser = appState.currentUser { | ||
| 19 | let vm = HomeViewModel(currentUser: currentUser, client: appState.client) | ||
| 20 | viewModel = vm | ||
| 21 | await vm.loadDashboard() | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | @ViewBuilder | ||
| 27 | private func content(_ viewModel: HomeViewModel) -> some View { | ||
| 28 | List { | ||
| 29 | assignedTicketsSection(viewModel) | ||
| 30 | recentBuildsSection(viewModel) | ||
| 31 | } | ||
| 32 | .listStyle(.insetGrouped) | ||
| 33 | .overlay { | ||
| 34 | if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | ||
| 35 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { | ||
| 36 | SRHTLoadingStateView(message: "Loading Home…") | ||
| 37 | } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | ||
| 38 | viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | ||
| 39 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { | ||
| 40 | ContentUnavailableView( | ||
| 41 | "All Clear", | ||
| 42 | systemImage: "checkmark.circle", | ||
| 43 | description: Text("There are no assigned tickets or recent builds right now.") | ||
| 44 | ) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | .refreshable { | ||
| 48 | await viewModel.loadDashboard() | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | @ViewBuilder | ||
| 53 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { | ||
| 54 | Section { | ||
| 55 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | ||
| 56 | HomeSectionLoadingRow(label: "Loading assigned tickets") | ||
| 57 | } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { | ||
| 58 | HomeSectionMessageRow( | ||
| 59 | text: "Couldn’t load assigned tickets.", | ||
| 60 | systemImage: "exclamationmark.triangle", | ||
| 61 | emphasized: true, | ||
| 62 | accessibilityHint: error | ||
| 63 | ) | ||
| 64 | } else if viewModel.assignedTickets.isEmpty { | ||
| 65 | HomeSectionMessageRow( | ||
| 66 | text: "No open tickets assigned to you.", | ||
| 67 | systemImage: "person.crop.circle.badge.checkmark" | ||
| 68 | ) | ||
| 69 | } else { | ||
| 70 | ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in | ||
| 71 | NavigationLink { | ||
| 72 | TicketDetailView( | ||
| 73 | ownerUsername: ticket.ownerUsername, | ||
| 74 | trackerName: ticket.trackerName, | ||
| 75 | trackerId: ticket.trackerId, | ||
| 76 | trackerRid: ticket.trackerRid, | ||
| 77 | ticketId: ticket.ticket.id | ||
| 78 | ) | ||
| 79 | } label: { | ||
| 80 | HomeAssignedTicketRow(ticket: ticket) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } header: { | ||
| 85 | HomeSectionHeader("Assigned Tickets") { | ||
| 86 | HomeAssignedTicketsListView(viewModel: viewModel) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | @ViewBuilder | ||
| 92 | private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { | ||
| 93 | Section { | ||
| 94 | if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { | ||
| 95 | HomeSectionLoadingRow(label: "Loading recent builds") | ||
| 96 | } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { | ||
| 97 | HomeSectionMessageRow( | ||
| 98 | text: "Couldn’t load recent builds.", | ||
| 99 | systemImage: "exclamationmark.triangle", | ||
| 100 | emphasized: true, | ||
| 101 | accessibilityHint: error | ||
| 102 | ) | ||
| 103 | } else if viewModel.recentBuilds.isEmpty { | ||
| 104 | HomeSectionMessageRow( | ||
| 105 | text: "No recent builds.", | ||
| 106 | systemImage: "clock" | ||
| 107 | ) | ||
| 108 | } else { | ||
| 109 | ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in | ||
| 110 | NavigationLink { | ||
| 111 | BuildDetailView(jobId: build.job.id) | ||
| 112 | } label: { | ||
| 113 | HomeBuildRow(build: build) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } header: { | ||
| 118 | HomeSectionHeader("Recent Builds") { | ||
| 119 | BuildListView() | ||
| 120 | } | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | } | ||
| 125 | |||
| 126 | private struct HomeBuildRow: View { | ||
| 127 | let build: HomeBuildItem | ||
| 128 | |||
| 129 | var body: some View { | ||
| 130 | HStack(spacing: 12) { | ||
| 131 | JobStatusIcon(status: build.job.status) | ||
| 132 | .frame(width: 20) | ||
| 133 | |||
| 134 | VStack(alignment: .leading, spacing: 4) { | ||
| 135 | Text(primaryTitle) | ||
| 136 | .font(.subheadline.weight(.medium)) | ||
| 137 | .lineLimit(1) | ||
| 138 | |||
| 139 | HStack(spacing: 8) { | ||
| 140 | Text("Job #\(build.job.id)") | ||
| 141 | .font(.caption) | ||
| 142 | .foregroundStyle(.secondary) | ||
| 143 | |||
| 144 | Text("•") | ||
| 145 | .font(.caption) | ||
| 146 | .foregroundStyle(.tertiary) | ||
| 147 | |||
| 148 | Text(build.job.status.rawValue.capitalized) | ||
| 149 | .font(.caption) | ||
| 150 | .foregroundStyle(.secondary) | ||
| 151 | |||
| 152 | Text("•") | ||
| 153 | .font(.caption) | ||
| 154 | .foregroundStyle(.tertiary) | ||
| 155 | |||
| 156 | Text(build.job.created.relativeDescription) | ||
| 157 | .font(.caption) | ||
| 158 | .foregroundStyle(.tertiary) | ||
| 159 | |||
| 160 | Spacer() | ||
| 161 | } | ||
| 162 | } | ||
| 163 | } | ||
| 164 | .padding(.vertical, 2) | ||
| 165 | } | ||
| 166 | |||
| 167 | private var primaryTitle: String { | ||
| 168 | if let repositoryDisplayName = build.repositoryDisplayName { | ||
| 169 | return repositoryDisplayName | ||
| 170 | } | ||
| 171 | return build.job.displayLabel | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | private struct HomeAssignedTicketRow: View { | ||
| 176 | let ticket: HomeAssignedTicket | ||
| 177 | |||
| 178 | var body: some View { | ||
| 179 | HStack(alignment: .top, spacing: 12) { | ||
| 180 | TicketStatusIcon(status: ticket.ticket.status) | ||
| 181 | .frame(width: 20) | ||
| 182 | |||
| 183 | VStack(alignment: .leading, spacing: 4) { | ||
| 184 | Text(ticket.ticket.title) | ||
| 185 | .font(.subheadline.weight(.medium)) | ||
| 186 | .lineLimit(2) | ||
| 187 | |||
| 188 | Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") | ||
| 189 | .font(.caption) | ||
| 190 | .foregroundStyle(.secondary) | ||
| 191 | .lineLimit(1) | ||
| 192 | .truncationMode(.tail) | ||
| 193 | } | ||
| 194 | |||
| 195 | Spacer(minLength: 8) | ||
| 196 | |||
| 197 | Text(ticket.ticket.status.displayName) | ||
| 198 | .font(.caption2.weight(.medium)) | ||
| 199 | .foregroundStyle(.secondary) | ||
| 200 | .lineLimit(1) | ||
| 201 | .fixedSize() | ||
| 202 | } | ||
| 203 | .padding(.vertical, 2) | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | private struct HomeSectionLoadingRow: View { | ||
| 208 | let label: String | ||
| 209 | |||
| 210 | var body: some View { | ||
| 211 | HStack(spacing: 10) { | ||
| 212 | ProgressView() | ||
| 213 | .controlSize(.small) | ||
| 214 | Text(label) | ||
| 215 | .foregroundStyle(.secondary) | ||
| 216 | } | ||
| 217 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | private struct HomeSectionHeader<Destination: View>: View { | ||
| 222 | let title: String | ||
| 223 | let destination: Destination | ||
| 224 | |||
| 225 | init(_ title: String, @ViewBuilder destination: () -> Destination) { | ||
| 226 | self.title = title | ||
| 227 | self.destination = destination() | ||
| 228 | } | ||
| 229 | |||
| 230 | var body: some View { | ||
| 231 | HStack { | ||
| 232 | Text(title) | ||
| 233 | Spacer() | ||
| 234 | NavigationLink { | ||
| 235 | destination | ||
| 236 | } label: { | ||
| 237 | Text("See All") | ||
| 238 | .font(.caption.weight(.medium)) | ||
| 239 | } | ||
| 240 | .buttonStyle(.plain) | ||
| 241 | } | ||
| 242 | .textCase(nil) | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | private struct HomeAssignedTicketsListView: View { | ||
| 247 | let viewModel: HomeViewModel | ||
| 248 | |||
| 249 | var body: some View { | ||
| 250 | List { | ||
| 251 | ForEach(viewModel.assignedTickets) { ticket in | ||
| 252 | NavigationLink { | ||
| 253 | TicketDetailView( | ||
| 254 | ownerUsername: ticket.ownerUsername, | ||
| 255 | trackerName: ticket.trackerName, | ||
| 256 | trackerId: ticket.trackerId, | ||
| 257 | trackerRid: ticket.trackerRid, | ||
| 258 | ticketId: ticket.ticket.id | ||
| 259 | ) | ||
| 260 | } label: { | ||
| 261 | HomeAssignedTicketRow(ticket: ticket) | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | ||
| 266 | HomeSectionMessageRow( | ||
| 267 | text: "No open tickets assigned to you.", | ||
| 268 | systemImage: "person.crop.circle.badge.checkmark" | ||
| 269 | ) | ||
| 270 | } | ||
| 271 | } | ||
| 272 | .navigationTitle("Assigned Tickets") | ||
| 273 | .navigationBarTitleDisplayMode(.inline) | ||
| 274 | .refreshable { | ||
| 275 | await viewModel.loadDashboard() | ||
| 276 | } | ||
| 277 | .overlay { | ||
| 278 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | ||
| 279 | SRHTLoadingStateView(message: "Loading assigned tickets…") | ||
| 280 | } | ||
| 281 | } | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | private struct HomeSectionMessageRow: View { | ||
| 286 | let text: String | ||
| 287 | let systemImage: String | ||
| 288 | var emphasized = false | ||
| 289 | var accessibilityHint: String? = nil | ||
| 290 | |||
| 291 | var body: some View { | ||
| 292 | Label(text, systemImage: systemImage) | ||
| 293 | .font(.subheadline) | ||
| 294 | .foregroundStyle(emphasized ? .secondary : .tertiary) | ||
| 295 | .accessibilityHint(accessibilityHint ?? "") | ||
| 296 | } | ||
| 297 | } | ||
Hutch/Views/Home/HomeViewModel.swift added +443
| @@ -0,0 +1,443 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | private struct HomeJobsResponse: Decodable, Sendable { | ||
| 4 | let jobs: HomeJobsPage | ||
| 5 | } | ||
| 6 | |||
| 7 | private struct HomeJobsPage: Decodable, Sendable { | ||
| 8 | let results: [HomeJobPayload] | ||
| 9 | } | ||
| 10 | |||
| 11 | private struct HomeTrackersResponse: Decodable, Sendable { | ||
| 12 | let trackers: HomeTrackersPage | ||
| 13 | } | ||
| 14 | |||
| 15 | private struct HomeTrackersPage: Decodable, Sendable { | ||
| 16 | let results: [TrackerSummary] | ||
| 17 | let cursor: String? | ||
| 18 | } | ||
| 19 | |||
| 20 | private struct HomeTrackerTicketsResponse: Decodable, Sendable { | ||
| 21 | let user: HomeTrackerTicketsUser | ||
| 22 | } | ||
| 23 | |||
| 24 | private struct HomeTrackerTicketsUser: Decodable, Sendable { | ||
| 25 | let tracker: HomeTrackerTicketsTracker | ||
| 26 | } | ||
| 27 | |||
| 28 | private struct HomeTrackerTicketsTracker: Decodable, Sendable { | ||
| 29 | let tickets: HomeTrackerTicketsPage | ||
| 30 | } | ||
| 31 | |||
| 32 | private struct HomeTrackerTicketsPage: Decodable, Sendable { | ||
| 33 | let results: [HomeTicketPayload] | ||
| 34 | } | ||
| 35 | |||
| 36 | private struct HomeTicketPayload: Decodable, Sendable { | ||
| 37 | let id: Int | ||
| 38 | let title: String | ||
| 39 | let status: TicketStatus | ||
| 40 | let resolution: TicketResolution? | ||
| 41 | let created: Date | ||
| 42 | let submitter: Entity | ||
| 43 | let labels: [TicketLabel] | ||
| 44 | let assignees: [Entity] | ||
| 45 | |||
| 46 | enum CodingKeys: String, CodingKey { | ||
| 47 | case id | ||
| 48 | case title = "subject" | ||
| 49 | case status | ||
| 50 | case resolution | ||
| 51 | case created | ||
| 52 | case submitter | ||
| 53 | case labels | ||
| 54 | case assignees | ||
| 55 | } | ||
| 56 | |||
| 57 | var ticketSummary: TicketSummary { | ||
| 58 | TicketSummary( | ||
| 59 | id: id, | ||
| 60 | title: title, | ||
| 61 | status: status, | ||
| 62 | resolution: resolution, | ||
| 63 | created: created, | ||
| 64 | submitter: submitter, | ||
| 65 | labels: labels, | ||
| 66 | assignees: assignees | ||
| 67 | ) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | struct HomeAssignedTicket: Identifiable, Hashable, Sendable { | ||
| 72 | let trackerId: Int | ||
| 73 | let trackerRid: String | ||
| 74 | let trackerName: String | ||
| 75 | let ownerCanonicalName: String | ||
| 76 | let ticket: TicketSummary | ||
| 77 | |||
| 78 | var id: String { | ||
| 79 | "\(trackerRid)#\(ticket.id)" | ||
| 80 | } | ||
| 81 | |||
| 82 | var ownerUsername: String { | ||
| 83 | if ownerCanonicalName.hasPrefix("~") { | ||
| 84 | return String(ownerCanonicalName.dropFirst()) | ||
| 85 | } | ||
| 86 | return ownerCanonicalName | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | struct HomeBuildItem: Identifiable, Hashable, Sendable { | ||
| 91 | let job: JobSummary | ||
| 92 | let repositoryName: String? | ||
| 93 | let repositoryOwner: String? | ||
| 94 | |||
| 95 | var id: Int { job.id } | ||
| 96 | |||
| 97 | var repositoryDisplayName: String? { | ||
| 98 | guard let repositoryName else { return nil } | ||
| 99 | if let repositoryOwner { | ||
| 100 | return "\(repositoryOwner)/\(repositoryName)" | ||
| 101 | } | ||
| 102 | return repositoryName | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | @Observable | ||
| 107 | @MainActor | ||
| 108 | final class HomeViewModel { | ||
| 109 | private(set) var failedBuilds: [HomeBuildItem] = [] | ||
| 110 | private(set) var assignedTickets: [HomeAssignedTicket] = [] | ||
| 111 | private(set) var recentBuilds: [HomeBuildItem] = [] | ||
| 112 | private(set) var isLoadingFailedBuilds = false | ||
| 113 | private(set) var isLoadingAssignedTickets = false | ||
| 114 | private(set) var isLoadingRecentBuilds = false | ||
| 115 | private(set) var failedBuildsError: String? | ||
| 116 | private(set) var assignedTicketsError: String? | ||
| 117 | private(set) var recentBuildsError: String? | ||
| 118 | |||
| 119 | private let currentUser: User | ||
| 120 | private let client: SRHTClient | ||
| 121 | private let ticketFetchConcurrencyLimit = 6 | ||
| 122 | |||
| 123 | private static let jobsQuery = """ | ||
| 124 | query jobs { | ||
| 125 | jobs { | ||
| 126 | results { | ||
| 127 | id | ||
| 128 | created | ||
| 129 | updated | ||
| 130 | status | ||
| 131 | note | ||
| 132 | tags | ||
| 133 | visibility | ||
| 134 | image | ||
| 135 | tasks { name status } | ||
| 136 | manifest | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | """ | ||
| 141 | |||
| 142 | private static let trackersQuery = """ | ||
| 143 | query trackers($cursor: Cursor) { | ||
| 144 | trackers(cursor: $cursor) { | ||
| 145 | results { | ||
| 146 | id | ||
| 147 | rid | ||
| 148 | name | ||
| 149 | description | ||
| 150 | visibility | ||
| 151 | updated | ||
| 152 | owner { canonicalName } | ||
| 153 | } | ||
| 154 | cursor | ||
| 155 | } | ||
| 156 | } | ||
| 157 | """ | ||
| 158 | |||
| 159 | private static let trackerTicketsQuery = """ | ||
| 160 | query tickets($owner: String!, $tracker: String!) { | ||
| 161 | user(username: $owner) { | ||
| 162 | tracker(name: $tracker) { | ||
| 163 | tickets { | ||
| 164 | results { | ||
| 165 | id | ||
| 166 | subject | ||
| 167 | status | ||
| 168 | resolution | ||
| 169 | created | ||
| 170 | submitter { canonicalName } | ||
| 171 | labels { id name backgroundColor foregroundColor } | ||
| 172 | assignees { canonicalName } | ||
| 173 | } | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } | ||
| 177 | } | ||
| 178 | """ | ||
| 179 | |||
| 180 | init(currentUser: User, client: SRHTClient) { | ||
| 181 | self.currentUser = currentUser | ||
| 182 | self.client = client | ||
| 183 | } | ||
| 184 | |||
| 185 | func loadDashboard() async { | ||
| 186 | isLoadingFailedBuilds = true | ||
| 187 | isLoadingAssignedTickets = true | ||
| 188 | isLoadingRecentBuilds = true | ||
| 189 | failedBuildsError = nil | ||
| 190 | assignedTicketsError = nil | ||
| 191 | recentBuildsError = nil | ||
| 192 | |||
| 193 | async let jobsTask = loadRecentJobs() | ||
| 194 | async let assignedTicketsTask = loadAssignedTickets() | ||
| 195 | |||
| 196 | let recentJobsResult = await jobsTask | ||
| 197 | |||
| 198 | switch recentJobsResult { | ||
| 199 | case .success(let recentJobs): | ||
| 200 | let buildItems = Self.buildItems(from: recentJobs) | ||
| 201 | self.recentBuilds = buildItems | ||
| 202 | self.failedBuilds = Self.failedBuilds(from: buildItems) | ||
| 203 | self.failedBuildsError = nil | ||
| 204 | self.recentBuildsError = nil | ||
| 205 | case .failure(let error): | ||
| 206 | self.recentBuilds = [] | ||
| 207 | self.failedBuilds = [] | ||
| 208 | self.failedBuildsError = error.localizedDescription | ||
| 209 | self.recentBuildsError = error.localizedDescription | ||
| 210 | } | ||
| 211 | isLoadingFailedBuilds = false | ||
| 212 | isLoadingRecentBuilds = false | ||
| 213 | |||
| 214 | let assignedTicketsResult = await assignedTicketsTask | ||
| 215 | |||
| 216 | switch assignedTicketsResult { | ||
| 217 | case .success(let assignedTickets): | ||
| 218 | self.assignedTickets = assignedTickets | ||
| 219 | self.assignedTicketsError = nil | ||
| 220 | case .failure(let error): | ||
| 221 | self.assignedTickets = [] | ||
| 222 | self.assignedTicketsError = error.localizedDescription | ||
| 223 | } | ||
| 224 | isLoadingAssignedTickets = false | ||
| 225 | } | ||
| 226 | |||
| 227 | private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { | ||
| 228 | do { | ||
| 229 | let response = try await client.execute( | ||
| 230 | service: .builds, | ||
| 231 | query: Self.jobsQuery, | ||
| 232 | responseType: HomeJobsResponse.self | ||
| 233 | ) | ||
| 234 | return .success(response.jobs.results) | ||
| 235 | } catch { | ||
| 236 | return .failure(error) | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { | ||
| 241 | do { | ||
| 242 | let trackers = try await fetchAllTrackers() | ||
| 243 | let tickets = try await fetchAssignedTickets(for: trackers) | ||
| 244 | .sorted { $0.ticket.created > $1.ticket.created } | ||
| 245 | return .success(tickets) | ||
| 246 | } catch { | ||
| 247 | return .failure(error) | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | private func fetchAllTrackers() async throws -> [TrackerSummary] { | ||
| 252 | var allTrackers: [TrackerSummary] = [] | ||
| 253 | var cursor: String? | ||
| 254 | |||
| 255 | while true { | ||
| 256 | var variables: [String: any Sendable] = [:] | ||
| 257 | if let cursor { | ||
| 258 | variables["cursor"] = cursor | ||
| 259 | } | ||
| 260 | |||
| 261 | let response = try await client.execute( | ||
| 262 | service: .todo, | ||
| 263 | query: Self.trackersQuery, | ||
| 264 | variables: variables.isEmpty ? nil : variables, | ||
| 265 | responseType: HomeTrackersResponse.self | ||
| 266 | ) | ||
| 267 | |||
| 268 | allTrackers.append(contentsOf: response.trackers.results) | ||
| 269 | guard let nextCursor = response.trackers.cursor else { | ||
| 270 | break | ||
| 271 | } | ||
| 272 | cursor = nextCursor | ||
| 273 | } | ||
| 274 | |||
| 275 | return allTrackers | ||
| 276 | } | ||
| 277 | |||
| 278 | private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { | ||
| 279 | guard !trackers.isEmpty else { return [] } | ||
| 280 | |||
| 281 | var assignedTickets: [HomeAssignedTicket] = [] | ||
| 282 | var startIndex = trackers.startIndex | ||
| 283 | |||
| 284 | while startIndex < trackers.endIndex { | ||
| 285 | let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex | ||
| 286 | let batch = Array(trackers[startIndex..<endIndex]) | ||
| 287 | |||
| 288 | let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in | ||
| 289 | for tracker in batch { | ||
| 290 | group.addTask { | ||
| 291 | try await self.fetchAssignedTickets(for: tracker) | ||
| 292 | } | ||
| 293 | } | ||
| 294 | |||
| 295 | var ticketsForBatch: [HomeAssignedTicket] = [] | ||
| 296 | for try await tickets in group { | ||
| 297 | ticketsForBatch.append(contentsOf: tickets) | ||
| 298 | } | ||
| 299 | return ticketsForBatch | ||
| 300 | } | ||
| 301 | |||
| 302 | assignedTickets.append(contentsOf: batchTickets) | ||
| 303 | startIndex = endIndex | ||
| 304 | } | ||
| 305 | |||
| 306 | return assignedTickets | ||
| 307 | } | ||
| 308 | |||
| 309 | private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { | ||
| 310 | let response = try await client.execute( | ||
| 311 | service: .todo, | ||
| 312 | query: Self.trackerTicketsQuery, | ||
| 313 | variables: [ | ||
| 314 | "owner": tracker.owner.canonicalName.hasPrefix("~") | ||
| 315 | ? String(tracker.owner.canonicalName.dropFirst()) | ||
| 316 | : tracker.owner.canonicalName, | ||
| 317 | "tracker": tracker.name | ||
| 318 | ], | ||
| 319 | responseType: HomeTrackerTicketsResponse.self | ||
| 320 | ) | ||
| 321 | |||
| 322 | return response.user.tracker.tickets.results.compactMap { payload in | ||
| 323 | guard payload.status.isOpen else { | ||
| 324 | return nil | ||
| 325 | } | ||
| 326 | guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else { | ||
| 327 | return nil | ||
| 328 | } | ||
| 329 | |||
| 330 | return HomeAssignedTicket( | ||
| 331 | trackerId: tracker.id, | ||
| 332 | trackerRid: tracker.rid, | ||
| 333 | trackerName: tracker.name, | ||
| 334 | ownerCanonicalName: tracker.owner.canonicalName, | ||
| 335 | ticket: payload.ticketSummary | ||
| 336 | ) | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | ||
| 341 | jobs.map { job in | ||
| 342 | let repository = primaryRepositoryReference(in: job.manifest) | ||
| 343 | return HomeBuildItem( | ||
| 344 | job: job.jobSummary, | ||
| 345 | repositoryName: repository?.name, | ||
| 346 | repositoryOwner: repository?.ownerCanonicalName | ||
| 347 | ) | ||
| 348 | } | ||
| 349 | } | ||
| 350 | |||
| 351 | nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { | ||
| 352 | builds.filter { build in | ||
| 353 | switch build.job.status { | ||
| 354 | case .failed, .timeout: | ||
| 355 | true | ||
| 356 | default: | ||
| 357 | false | ||
| 358 | } | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | ||
| 363 | failedBuilds(from: buildItems(from: jobs)) | ||
| 364 | } | ||
| 365 | |||
| 366 | nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { | ||
| 367 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) | ||
| 368 | let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) | ||
| 369 | if assigneeCanonical == currentCanonical { | ||
| 370 | return true | ||
| 371 | } | ||
| 372 | |||
| 373 | let assigneeUsername = normalizedUsername(entity.canonicalName) | ||
| 374 | let currentUsername = normalizedUsername(currentUser.username) | ||
| 375 | return assigneeUsername == currentUsername | ||
| 376 | } | ||
| 377 | |||
| 378 | nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? { | ||
| 379 | guard let manifest else { return nil } | ||
| 380 | let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# | ||
| 381 | guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { | ||
| 382 | return nil | ||
| 383 | } | ||
| 384 | let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) | ||
| 385 | guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange), | ||
| 386 | let ownerRange = Range(match.range(at: 1), in: manifest), | ||
| 387 | let nameRange = Range(match.range(at: 2), in: manifest) else { | ||
| 388 | return nil | ||
| 389 | } | ||
| 390 | |||
| 391 | let owner = String(manifest[ownerRange]) | ||
| 392 | var name = String(manifest[nameRange]) | ||
| 393 | if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { | ||
| 394 | name.removeSubrange(suffixRange) | ||
| 395 | } | ||
| 396 | name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 397 | guard !name.isEmpty else { return nil } | ||
| 398 | return (owner, name) | ||
| 399 | } | ||
| 400 | |||
| 401 | private nonisolated static func normalizedCanonicalName(_ value: String) -> String { | ||
| 402 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 403 | guard !trimmed.isEmpty else { return trimmed } | ||
| 404 | if trimmed.hasPrefix("~") { | ||
| 405 | return trimmed | ||
| 406 | } | ||
| 407 | return "~\(trimmed)" | ||
| 408 | } | ||
| 409 | |||
| 410 | private nonisolated static func normalizedUsername(_ value: String) -> String { | ||
| 411 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 412 | if trimmed.hasPrefix("~") { | ||
| 413 | return String(trimmed.dropFirst()) | ||
| 414 | } | ||
| 415 | return trimmed | ||
| 416 | } | ||
| 417 | } | ||
| 418 | struct HomeJobPayload: Decodable, Sendable { | ||
| 419 | let id: Int | ||
| 420 | let created: Date | ||
| 421 | let updated: Date | ||
| 422 | let status: JobStatus | ||
| 423 | let note: String? | ||
| 424 | let tags: [String] | ||
| 425 | let visibility: Visibility? | ||
| 426 | let image: String? | ||
| 427 | let tasks: [JobTaskSummary] | ||
| 428 | let manifest: String? | ||
| 429 | |||
| 430 | nonisolated var jobSummary: JobSummary { | ||
| 431 | JobSummary( | ||
| 432 | id: id, | ||
| 433 | created: created, | ||
| 434 | updated: updated, | ||
| 435 | status: status, | ||
| 436 | note: note, | ||
| 437 | tags: tags, | ||
| 438 | visibility: visibility, | ||
| 439 | image: image, | ||
| 440 | tasks: tasks | ||
| 441 | ) | ||
| 442 | } | ||
| 443 | } | ||
Hutch/Views/Repositories/RepositoryListView.swift +4 −1
| @@ -63,7 +63,10 @@ struct RepositoryListView: View { | |||
| 63 | List { | 63 | List { |
| 64 | ForEach(viewModel.repositories) { repo in | 64 | ForEach(viewModel.repositories) { repo in |
| 65 | NavigationLink(value: repo) { | 65 | NavigationLink(value: repo) { |
| 66 | RepositoryRowView(repository: repo) | 66 | RepositoryRowView( |
| 67 | repository: repo, | ||
| 68 | buildStatus: viewModel.latestBuildStatus(for: repo) | ||
| 69 | ) | ||
| 67 | } | 70 | } |
| 68 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } | 71 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 69 | .task { | 72 | .task { |
Hutch/Views/Repositories/RepositoryListViewModel.swift +185
| @@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { | |||
| 27 | final class RepositoryListViewModel { | 27 | final class RepositoryListViewModel { |
| 28 | 28 | ||
| 29 | private(set) var repositories: [RepositorySummary] = [] | 29 | private(set) var repositories: [RepositorySummary] = [] |
| 30 | private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:] | ||
| 30 | private(set) var isLoading = false | 31 | private(set) var isLoading = false |
| 31 | private(set) var isLoadingMore = false | 32 | private(set) var isLoadingMore = false |
| 32 | private(set) var isRefreshing = false | 33 | private(set) var isRefreshing = false |
| @@ -41,9 +42,11 @@ final class RepositoryListViewModel { | |||
| 41 | private(set) var hasLoadedSearchIndex = false | 42 | private(set) var hasLoadedSearchIndex = false |
| 42 | private var searchIndex: [RepositorySummary] = [] | 43 | private var searchIndex: [RepositorySummary] = [] |
| 43 | private let client: SRHTClient | 44 | private let client: SRHTClient |
| 45 | private var buildStatusTask: Task<Void, Never>? | ||
| 44 | 46 | ||
| 45 | private static let gitCacheKey = "git.repositories" | 47 | private static let gitCacheKey = "git.repositories" |
| 46 | private static let hgCacheKey = "hg.repositories" | 48 | private static let hgCacheKey = "hg.repositories" |
| 49 | private static let buildsCacheKey = "builds.repository-status" | ||
| 47 | private static let minimumRemoteSearchLength = 3 | 50 | private static let minimumRemoteSearchLength = 3 |
| 48 | 51 | ||
| 49 | init(client: SRHTClient) { | 52 | init(client: SRHTClient) { |
| @@ -117,6 +120,20 @@ final class RepositoryListViewModel { | |||
| 117 | } | 120 | } |
| 118 | """ | 121 | """ |
| 119 | 122 | ||
| 123 | private static let buildsQuery = """ | ||
| 124 | query jobs($cursor: Cursor) { | ||
| 125 | jobs(cursor: $cursor) { | ||
| 126 | results { | ||
| 127 | id | ||
| 128 | created | ||
| 129 | status | ||
| 130 | manifest | ||
| 131 | } | ||
| 132 | cursor | ||
| 133 | } | ||
| 134 | } | ||
| 135 | """ | ||
| 136 | |||
| 120 | // MARK: - Public API | 137 | // MARK: - Public API |
| 121 | 138 | ||
| 122 | /// Fetch the first page of repositories. Shows cached data instantly if available, | 139 | /// Fetch the first page of repositories. Shows cached data instantly if available, |
| @@ -168,6 +185,7 @@ final class RepositoryListViewModel { | |||
| 168 | } | 185 | } |
| 169 | 186 | ||
| 170 | repositories = filteredResults.sorted(by: repositorySortOrder) | 187 | repositories = filteredResults.sorted(by: repositorySortOrder) |
| 188 | scheduleBuildStatusRefresh() | ||
| 171 | } catch { | 189 | } catch { |
| 172 | // Only show error if we have no cached data to fall back on | 190 | // Only show error if we have no cached data to fall back on |
| 173 | if repositories.isEmpty { | 191 | if repositories.isEmpty { |
| @@ -245,6 +263,7 @@ final class RepositoryListViewModel { | |||
| 245 | } | 263 | } |
| 246 | repositories.insert(repository, at: 0) | 264 | repositories.insert(repository, at: 0) |
| 247 | insertIntoSearchIndex(repository) | 265 | insertIntoSearchIndex(repository) |
| 266 | scheduleBuildStatusRefresh() | ||
| 248 | return repository | 267 | return repository |
| 249 | } catch { | 268 | } catch { |
| 250 | self.error = repositoryCreationErrorMessage(for: error) | 269 | self.error = repositoryCreationErrorMessage(for: error) |
| @@ -306,6 +325,22 @@ final class RepositoryListViewModel { | |||
| 306 | let createRepository: HGRepositoryPayload | 325 | let createRepository: HGRepositoryPayload |
| 307 | } | 326 | } |
| 308 | 327 | ||
| 328 | private struct BuildJobsResponse: Decodable, Sendable { | ||
| 329 | let jobs: BuildJobsPage | ||
| 330 | } | ||
| 331 | |||
| 332 | private struct BuildJobsPage: Decodable, Sendable { | ||
| 333 | let results: [BuildStatusPayload] | ||
| 334 | let cursor: String? | ||
| 335 | } | ||
| 336 | |||
| 337 | private struct BuildStatusPayload: Decodable, Sendable { | ||
| 338 | let id: Int | ||
| 339 | let created: Date | ||
| 340 | let status: JobStatus | ||
| 341 | let manifest: String? | ||
| 342 | } | ||
| 343 | |||
| 309 | private struct HGPage: Decodable, Sendable { | 344 | private struct HGPage: Decodable, Sendable { |
| 310 | let results: [HGRepositoryPayload] | 345 | let results: [HGRepositoryPayload] |
| 311 | let cursor: String? | 346 | let cursor: String? |
| @@ -380,6 +415,10 @@ final class RepositoryListViewModel { | |||
| 380 | searchIndex | 415 | searchIndex |
| 381 | } | 416 | } |
| 382 | 417 | ||
| 418 | func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus { | ||
| 419 | latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none | ||
| 420 | } | ||
| 421 | |||
| 383 | private func fetchPage( | 422 | private func fetchPage( |
| 384 | service: SRHTService, | 423 | service: SRHTService, |
| 385 | cursor: String?, | 424 | cursor: String?, |
| @@ -514,9 +553,94 @@ final class RepositoryListViewModel { | |||
| 514 | let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) | 553 | let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) |
| 515 | repositories = sortedRepositories | 554 | repositories = sortedRepositories |
| 516 | updateSearchIndex(with: sortedRepositories) | 555 | updateSearchIndex(with: sortedRepositories) |
| 556 | scheduleBuildStatusRefresh() | ||
| 517 | } | 557 | } |
| 518 | } | 558 | } |
| 519 | 559 | ||
| 560 | private func scheduleBuildStatusRefresh() { | ||
| 561 | let repositoriesSnapshot = repositories | ||
| 562 | buildStatusTask?.cancel() | ||
| 563 | buildStatusTask = Task { [weak self] in | ||
| 564 | guard let self else { return } | ||
| 565 | await self.loadLatestBuildStatuses(for: repositoriesSnapshot) | ||
| 566 | } | ||
| 567 | } | ||
| 568 | |||
| 569 | private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async { | ||
| 570 | let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:))) | ||
| 571 | guard !targetKeys.isEmpty else { | ||
| 572 | await MainActor.run { | ||
| 573 | latestBuildStatuses = [:] | ||
| 574 | } | ||
| 575 | return | ||
| 576 | } | ||
| 577 | |||
| 578 | var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:] | ||
| 579 | var cursor: String? | ||
| 580 | var shouldUseCache = true | ||
| 581 | |||
| 582 | do { | ||
| 583 | while !Task.isCancelled { | ||
| 584 | let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache) | ||
| 585 | shouldUseCache = false | ||
| 586 | |||
| 587 | for job in page.results { | ||
| 588 | let jobStatus = Self.repositoryBuildStatus(for: job.status) | ||
| 589 | guard let manifest = job.manifest else { continue } | ||
| 590 | |||
| 591 | for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) { | ||
| 592 | let existing = resolvedStatuses[key] | ||
| 593 | if existing == nil || existing!.0 < job.created { | ||
| 594 | resolvedStatuses[key] = (job.created, jobStatus) | ||
| 595 | } | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | if resolvedStatuses.count == targetKeys.count || page.cursor == nil { | ||
| 600 | break | ||
| 601 | } | ||
| 602 | cursor = page.cursor | ||
| 603 | } | ||
| 604 | |||
| 605 | let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in | ||
| 606 | result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none | ||
| 607 | } | ||
| 608 | |||
| 609 | await MainActor.run { | ||
| 610 | guard repositories == self.repositories else { return } | ||
| 611 | latestBuildStatuses = finalStatuses | ||
| 612 | } | ||
| 613 | } catch { | ||
| 614 | // Build status is auxiliary data for the list. Leave the default gray state on failure. | ||
| 615 | } | ||
| 616 | } | ||
| 617 | |||
| 618 | private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage { | ||
| 619 | var variables: [String: any Sendable] = [:] | ||
| 620 | if let cursor { | ||
| 621 | variables["cursor"] = cursor | ||
| 622 | } | ||
| 623 | |||
| 624 | if useCache && cursor == nil { | ||
| 625 | let result = try await client.executeAndCache( | ||
| 626 | service: .builds, | ||
| 627 | query: Self.buildsQuery, | ||
| 628 | variables: variables.isEmpty ? nil : variables, | ||
| 629 | responseType: BuildJobsResponse.self, | ||
| 630 | cacheKey: Self.buildsCacheKey | ||
| 631 | ) | ||
| 632 | return result.jobs | ||
| 633 | } | ||
| 634 | |||
| 635 | let result = try await client.execute( | ||
| 636 | service: .builds, | ||
| 637 | query: Self.buildsQuery, | ||
| 638 | variables: variables.isEmpty ? nil : variables, | ||
| 639 | responseType: BuildJobsResponse.self | ||
| 640 | ) | ||
| 641 | return result.jobs | ||
| 642 | } | ||
| 643 | |||
| 520 | private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { | 644 | private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { |
| 521 | var allRepositories: [RepositorySummary] = [] | 645 | var allRepositories: [RepositorySummary] = [] |
| 522 | var currentCursor: String? = nil | 646 | var currentCursor: String? = nil |
| @@ -580,6 +704,67 @@ final class RepositoryListViewModel { | |||
| 580 | repo.description?.lowercased().contains(lowercasedQuery) ?? false | 704 | repo.description?.lowercased().contains(lowercasedQuery) ?? false |
| 581 | } | 705 | } |
| 582 | } | 706 | } |
| 707 | |||
| 708 | nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String { | ||
| 709 | buildStatusCacheKey( | ||
| 710 | service: repository.service, | ||
| 711 | ownerCanonicalName: repository.owner.canonicalName, | ||
| 712 | repositoryName: repository.name | ||
| 713 | ) | ||
| 714 | } | ||
| 715 | |||
| 716 | nonisolated static func buildStatusCacheKey( | ||
| 717 | service: SRHTService, | ||
| 718 | ownerCanonicalName: String, | ||
| 719 | repositoryName: String | ||
| 720 | ) -> String { | ||
| 721 | "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())" | ||
| 722 | } | ||
| 723 | |||
| 724 | nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus { | ||
| 725 | switch jobStatus { | ||
| 726 | case .success: | ||
| 727 | .success | ||
| 728 | case .pending, .queued, .running: | ||
| 729 | .running | ||
| 730 | case .failed, .cancelled, .timeout: | ||
| 731 | .failed | ||
| 732 | } | ||
| 733 | } | ||
| 734 | |||
| 735 | nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> { | ||
| 736 | let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# | ||
| 737 | guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { | ||
| 738 | return [] | ||
| 739 | } | ||
| 740 | |||
| 741 | let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) | ||
| 742 | return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in | ||
| 743 | guard | ||
| 744 | let serviceRange = Range(match.range(at: 1), in: manifest), | ||
| 745 | let ownerRange = Range(match.range(at: 2), in: manifest), | ||
| 746 | let nameRange = Range(match.range(at: 3), in: manifest) | ||
| 747 | else { | ||
| 748 | return | ||
| 749 | } | ||
| 750 | |||
| 751 | let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git | ||
| 752 | let owner = String(manifest[ownerRange]).lowercased() | ||
| 753 | var name = String(manifest[nameRange]).lowercased() | ||
| 754 | |||
| 755 | if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { | ||
| 756 | name.removeSubrange(suffixRange) | ||
| 757 | } | ||
| 758 | name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 759 | if !name.isEmpty { | ||
| 760 | result.insert(buildStatusCacheKey( | ||
| 761 | service: service, | ||
| 762 | ownerCanonicalName: owner, | ||
| 763 | repositoryName: name | ||
| 764 | )) | ||
| 765 | } | ||
| 766 | } | ||
| 767 | } | ||
| 583 | } | 768 | } |
| 584 | 769 | ||
| 585 | private extension Array { | 770 | private extension Array { |
Hutch/Views/Repositories/RepositoryRowView.swift +45
| @@ -2,6 +2,7 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct RepositoryRowView: View { | 3 | struct RepositoryRowView: View { |
| 4 | let repository: RepositorySummary | 4 | let repository: RepositorySummary |
| 5 | let buildStatus: RepositoryBuildStatus | ||
| 5 | 6 | ||
| 6 | var body: some View { | 7 | var body: some View { |
| 7 | VStack(alignment: .leading, spacing: 4) { | 8 | VStack(alignment: .leading, spacing: 4) { |
| @@ -20,6 +21,9 @@ struct RepositoryRowView: View { | |||
| 20 | .foregroundStyle(.cyan) | 21 | .foregroundStyle(.cyan) |
| 21 | } | 22 | } |
| 22 | 23 | ||
| 24 | if buildStatus != .none { | ||
| 25 | RepositoryBuildStatusIndicator(status: buildStatus) | ||
| 26 | } | ||
| 23 | VisibilityBadge(visibility: repository.visibility) | 27 | VisibilityBadge(visibility: repository.visibility) |
| 24 | } | 28 | } |
| 25 | 29 | ||
| @@ -53,6 +57,47 @@ struct RepositoryRowView: View { | |||
| 53 | } | 57 | } |
| 54 | } | 58 | } |
| 55 | 59 | ||
| 60 | private struct RepositoryBuildStatusIndicator: View { | ||
| 61 | let status: RepositoryBuildStatus | ||
| 62 | |||
| 63 | var body: some View { | ||
| 64 | Circle() | ||
| 65 | .fill(color) | ||
| 66 | .frame(width: 8, height: 8) | ||
| 67 | .overlay { | ||
| 68 | Circle() | ||
| 69 | .strokeBorder(.primary.opacity(0.08)) | ||
| 70 | } | ||
| 71 | .accessibilityLabel(accessibilityLabel) | ||
| 72 | } | ||
| 73 | |||
| 74 | private var color: Color { | ||
| 75 | switch status { | ||
| 76 | case .success: | ||
| 77 | .green | ||
| 78 | case .failed: | ||
| 79 | .red | ||
| 80 | case .running: | ||
| 81 | .orange | ||
| 82 | case .none: | ||
| 83 | .clear | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | private var accessibilityLabel: String { | ||
| 88 | switch status { | ||
| 89 | case .success: | ||
| 90 | "Latest build succeeded" | ||
| 91 | case .failed: | ||
| 92 | "Latest build failed" | ||
| 93 | case .running: | ||
| 94 | "Latest build is running" | ||
| 95 | case .none: | ||
| 96 | "No recent builds" | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 56 | // MARK: - VisibilityBadge | 101 | // MARK: - VisibilityBadge |
| 57 | 102 | ||
| 58 | struct VisibilityBadge: View { | 103 | struct VisibilityBadge: View { |
Hutch/Views/Repositories/RepositorySummarySupport.swift +7
| @@ -1,5 +1,12 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | enum RepositoryBuildStatus: Sendable { | ||
| 4 | case success | ||
| 5 | case failed | ||
| 6 | case running | ||
| 7 | case none | ||
| 8 | } | ||
| 9 | |||
| 3 | struct RepositoryCloneURLs { | 10 | struct RepositoryCloneURLs { |
| 4 | let readOnly: String | 11 | let readOnly: String |
| 5 | let readWrite: String | 12 | let readWrite: String |
HutchTests/HomeViewModelTests.swift added +67
| @@ -0,0 +1,67 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct HomeViewModelTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func failedBuildsKeepsOnlyFailedAndTimedOutJobs() { | ||
| 9 | let jobs = [ | ||
| 10 | makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)), | ||
| 11 | makeJob(id: 2, status: .failed, created: Date(timeIntervalSince1970: 20)), | ||
| 12 | makeJob(id: 3, status: .timeout, created: Date(timeIntervalSince1970: 30)), | ||
| 13 | makeJob(id: 4, status: .running, created: Date(timeIntervalSince1970: 40)) | ||
| 14 | ] | ||
| 15 | |||
| 16 | let failedBuilds = HomeViewModel.failedBuilds(from: jobs) | ||
| 17 | |||
| 18 | #expect(failedBuilds.map(\.job.id) == [2, 3]) | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test | ||
| 22 | func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() { | ||
| 23 | let currentUser = User( | ||
| 24 | id: 42, | ||
| 25 | username: "owner", | ||
| 26 | canonicalName: "~owner", | ||
| 27 | email: "owner@example.com", | ||
| 28 | avatar: nil | ||
| 29 | ) | ||
| 30 | |||
| 31 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~owner"), currentUser: currentUser)) | ||
| 32 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "owner"), currentUser: currentUser)) | ||
| 33 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: currentUser.username), currentUser: currentUser)) | ||
| 34 | #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~someone-else"), currentUser: currentUser) == false) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test | ||
| 38 | func primaryRepositoryReferenceParsesManifestSourceURL() { | ||
| 39 | let manifest = """ | ||
| 40 | image: alpine/latest | ||
| 41 | sources: | ||
| 42 | - https://git.sr.ht/~owner/hutch | ||
| 43 | tasks: | ||
| 44 | - true | ||
| 45 | """ | ||
| 46 | |||
| 47 | let repository = HomeViewModel.primaryRepositoryReference(in: manifest) | ||
| 48 | |||
| 49 | #expect(repository?.ownerCanonicalName == "~owner") | ||
| 50 | #expect(repository?.name == "hutch") | ||
| 51 | } | ||
| 52 | |||
| 53 | private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload { | ||
| 54 | HomeJobPayload( | ||
| 55 | id: id, | ||
| 56 | created: created, | ||
| 57 | updated: created, | ||
| 58 | status: status, | ||
| 59 | note: nil, | ||
| 60 | tags: [], | ||
| 61 | visibility: nil, | ||
| 62 | image: nil, | ||
| 63 | tasks: [], | ||
| 64 | manifest: nil | ||
| 65 | ) | ||
| 66 | } | ||
| 67 | } | ||
HutchTests/RepositoryListViewModelTests.swift +26
| @@ -28,6 +28,32 @@ struct RepositoryListViewModelTests { | |||
| 28 | #expect(descriptionMatches.map(\.id) == [2]) | 28 | #expect(descriptionMatches.map(\.id) == [2]) |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | @Test | ||
| 32 | func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() { | ||
| 33 | let manifest = """ | ||
| 34 | image: alpine/latest | ||
| 35 | sources: | ||
| 36 | - https://git.sr.ht/~owner/hutch | ||
| 37 | - ssh://hg@hg.sr.ht/~owner/wiki | ||
| 38 | tasks: | ||
| 39 | - echo "build" | ||
| 40 | """ | ||
| 41 | |||
| 42 | let keys = RepositoryListViewModel.buildStatusKeys(in: manifest) | ||
| 43 | |||
| 44 | #expect(keys.contains("git|~owner|hutch")) | ||
| 45 | #expect(keys.contains("hg|~owner|wiki")) | ||
| 46 | } | ||
| 47 | |||
| 48 | @Test | ||
| 49 | func repositoryBuildStatusMapsJobStatesToRowStates() { | ||
| 50 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .success) == .success) | ||
| 51 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .running) == .running) | ||
| 52 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .queued) == .running) | ||
| 53 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .failed) == .failed) | ||
| 54 | #expect(RepositoryListViewModel.repositoryBuildStatus(for: .timeout) == .failed) | ||
| 55 | } | ||
| 56 | |||
| 31 | @MainActor | 57 | @MainActor |
| 32 | private func makeRepository( | 58 | private func makeRepository( |
| 33 | id: Int, | 59 | id: Int, |