Commit 4c67bc570f

4c67bc570ffaa25dd84f11dc4f77527792a5602b

parent: ce9f6b7c8d

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 03:56 UTC

add Home dashboard and repository build status indicators

Layout: unified · split

Hutch/App/RootView.swift +13 −2
@@ -6,13 +6,15 @@ struct RootView: View {
66 @Environment(AppState.self) private var appState
77
88 enum Tab: Hashable {
9 case home
910 case repositories
1011 case builds
1112 case tickets
1213 case settings
1314 }
1415
15 @State private var selectedTab: Tab = .repositories
16 @State private var selectedTab: Tab = .home
17 @State private var homePath = NavigationPath()
1618 @State private var repoPath = NavigationPath()
1719 @State private var buildsPath = NavigationPath()
1820 @State private var ticketsPath = NavigationPath()
@@ -47,6 +49,14 @@ struct RootView: View {
4749
4850 private var tabContent: some View {
4951 TabView(selection: $selectedTab) {
52 NavigationStack(path: $homePath) {
53 HomeView()
54 }
55 .tag(Tab.home)
56 .tabItem {
57 Label("Home", systemImage: "house")
58 }
59
5060 NavigationStack(path: $repoPath) {
5161 RepositoryListView()
5262 }
@@ -106,10 +116,11 @@ struct RootView: View {
106116 case .launching:
107117 break
108118 case .unauthenticated:
119 homePath = NavigationPath()
109120 repoPath = NavigationPath()
110121 buildsPath = NavigationPath()
111122 ticketsPath = NavigationPath()
112 selectedTab = .repositories
123 selectedTab = .home
113124 isResolvingDeepLink = false
114125 case .authenticated:
115126 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
Hutch/Views/Home/HomeView.swift added +297
@@ -0,0 +1,297 @@
1import SwiftUI
2
3struct 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
126private 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
175private 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
207private 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
221private 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
246private 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
285private 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 @@
1import Foundation
2
3private struct HomeJobsResponse: Decodable, Sendable {
4 let jobs: HomeJobsPage
5}
6
7private struct HomeJobsPage: Decodable, Sendable {
8 let results: [HomeJobPayload]
9}
10
11private struct HomeTrackersResponse: Decodable, Sendable {
12 let trackers: HomeTrackersPage
13}
14
15private struct HomeTrackersPage: Decodable, Sendable {
16 let results: [TrackerSummary]
17 let cursor: String?
18}
19
20private struct HomeTrackerTicketsResponse: Decodable, Sendable {
21 let user: HomeTrackerTicketsUser
22}
23
24private struct HomeTrackerTicketsUser: Decodable, Sendable {
25 let tracker: HomeTrackerTicketsTracker
26}
27
28private struct HomeTrackerTicketsTracker: Decodable, Sendable {
29 let tickets: HomeTrackerTicketsPage
30}
31
32private struct HomeTrackerTicketsPage: Decodable, Sendable {
33 let results: [HomeTicketPayload]
34}
35
36private 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
71struct 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
90struct 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
108final 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}
418struct 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 {
6363 List {
6464 ForEach(viewModel.repositories) { repo in
6565 NavigationLink(value: repo) {
66 RepositoryRowView(repository: repo)
66 RepositoryRowView(
67 repository: repo,
68 buildStatus: viewModel.latestBuildStatus(for: repo)
69 )
6770 }
6871 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
6972 .task {
Hutch/Views/Repositories/RepositoryListViewModel.swift +185
@@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
2727final class RepositoryListViewModel {
2828
2929 private(set) var repositories: [RepositorySummary] = []
30 private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
3031 private(set) var isLoading = false
3132 private(set) var isLoadingMore = false
3233 private(set) var isRefreshing = false
@@ -41,9 +42,11 @@ final class RepositoryListViewModel {
4142 private(set) var hasLoadedSearchIndex = false
4243 private var searchIndex: [RepositorySummary] = []
4344 private let client: SRHTClient
45 private var buildStatusTask: Task<Void, Never>?
4446
4547 private static let gitCacheKey = "git.repositories"
4648 private static let hgCacheKey = "hg.repositories"
49 private static let buildsCacheKey = "builds.repository-status"
4750 private static let minimumRemoteSearchLength = 3
4851
4952 init(client: SRHTClient) {
@@ -117,6 +120,20 @@ final class RepositoryListViewModel {
117120 }
118121 """
119122
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
120137 // MARK: - Public API
121138
122139 /// Fetch the first page of repositories. Shows cached data instantly if available,
@@ -168,6 +185,7 @@ final class RepositoryListViewModel {
168185 }
169186
170187 repositories = filteredResults.sorted(by: repositorySortOrder)
188 scheduleBuildStatusRefresh()
171189 } catch {
172190 // Only show error if we have no cached data to fall back on
173191 if repositories.isEmpty {
@@ -245,6 +263,7 @@ final class RepositoryListViewModel {
245263 }
246264 repositories.insert(repository, at: 0)
247265 insertIntoSearchIndex(repository)
266 scheduleBuildStatusRefresh()
248267 return repository
249268 } catch {
250269 self.error = repositoryCreationErrorMessage(for: error)
@@ -306,6 +325,22 @@ final class RepositoryListViewModel {
306325 let createRepository: HGRepositoryPayload
307326 }
308327
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
309344 private struct HGPage: Decodable, Sendable {
310345 let results: [HGRepositoryPayload]
311346 let cursor: String?
@@ -380,6 +415,10 @@ final class RepositoryListViewModel {
380415 searchIndex
381416 }
382417
418 func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
419 latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
420 }
421
383422 private func fetchPage(
384423 service: SRHTService,
385424 cursor: String?,
@@ -514,9 +553,94 @@ final class RepositoryListViewModel {
514553 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
515554 repositories = sortedRepositories
516555 updateSearchIndex(with: sortedRepositories)
556 scheduleBuildStatusRefresh()
517557 }
518558 }
519559
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
520644 private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
521645 var allRepositories: [RepositorySummary] = []
522646 var currentCursor: String? = nil
@@ -580,6 +704,67 @@ final class RepositoryListViewModel {
580704 repo.description?.lowercased().contains(lowercasedQuery) ?? false
581705 }
582706 }
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 }
583768}
584769
585770private extension Array {
Hutch/Views/Repositories/RepositoryRowView.swift +45
@@ -2,6 +2,7 @@ import SwiftUI
22
33struct RepositoryRowView: View {
44 let repository: RepositorySummary
5 let buildStatus: RepositoryBuildStatus
56
67 var body: some View {
78 VStack(alignment: .leading, spacing: 4) {
@@ -20,6 +21,9 @@ struct RepositoryRowView: View {
2021 .foregroundStyle(.cyan)
2122 }
2223
24 if buildStatus != .none {
25 RepositoryBuildStatusIndicator(status: buildStatus)
26 }
2327 VisibilityBadge(visibility: repository.visibility)
2428 }
2529
@@ -53,6 +57,47 @@ struct RepositoryRowView: View {
5357 }
5458}
5559
60private 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
56101// MARK: - VisibilityBadge
57102
58103struct VisibilityBadge: View {
Hutch/Views/Repositories/RepositorySummarySupport.swift +7
@@ -1,5 +1,12 @@
11import SwiftUI
22
3enum RepositoryBuildStatus: Sendable {
4 case success
5 case failed
6 case running
7 case none
8}
9
310struct RepositoryCloneURLs {
411 let readOnly: String
512 let readWrite: String
HutchTests/HomeViewModelTests.swift added +67
@@ -0,0 +1,67 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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 {
2828 #expect(descriptionMatches.map(\.id) == [2])
2929 }
3030
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
3157 @MainActor
3258 private func makeRepository(
3359 id: Int,