summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-18 22:56:34 -0500
committerChristian Cleberg <[email protected]>2026-03-18 22:56:34 -0500
commitffc0ef0988b5e7d01951c23af85a1cf026b3da9f (patch)
tree868cef24e91ddaa5e7d5e39362d97ec6a94107c9 /Hutch
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
downloadhutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.gz
hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.bz2
hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.zip
add Home dashboard and repository build status indicators
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/RootView.swift15
-rw-r--r--Hutch/Views/Home/HomeView.swift297
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift443
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift5
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift185
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift45
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift7
7 files changed, 994 insertions, 3 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 7af162d..79411ed 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -6,13 +6,15 @@ struct RootView: View {
@Environment(AppState.self) private var appState
enum Tab: Hashable {
+ case home
case repositories
case builds
case tickets
case settings
}
- @State private var selectedTab: Tab = .repositories
+ @State private var selectedTab: Tab = .home
+ @State private var homePath = NavigationPath()
@State private var repoPath = NavigationPath()
@State private var buildsPath = NavigationPath()
@State private var ticketsPath = NavigationPath()
@@ -47,6 +49,14 @@ struct RootView: View {
private var tabContent: some View {
TabView(selection: $selectedTab) {
+ NavigationStack(path: $homePath) {
+ HomeView()
+ }
+ .tag(Tab.home)
+ .tabItem {
+ Label("Home", systemImage: "house")
+ }
+
NavigationStack(path: $repoPath) {
RepositoryListView()
}
@@ -106,10 +116,11 @@ struct RootView: View {
case .launching:
break
case .unauthenticated:
+ homePath = NavigationPath()
repoPath = NavigationPath()
buildsPath = NavigationPath()
ticketsPath = NavigationPath()
- selectedTab = .repositories
+ selectedTab = .home
isResolvingDeepLink = false
case .authenticated:
consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
new file mode 100644
index 0000000..a72853f
--- /dev/null
+++ b/Hutch/Views/Home/HomeView.swift
@@ -0,0 +1,297 @@
+import SwiftUI
+
+struct HomeView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: HomeViewModel?
+ private let previewLimit = 4
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading Home…")
+ }
+ }
+ .navigationTitle("Home")
+ .task {
+ if viewModel == nil, let currentUser = appState.currentUser {
+ let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
+ viewModel = vm
+ await vm.loadDashboard()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: HomeViewModel) -> some View {
+ List {
+ assignedTicketsSection(viewModel)
+ recentBuildsSection(viewModel)
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+ viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ SRHTLoadingStateView(message: "Loading Home…")
+ } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
+ viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
+ ContentUnavailableView(
+ "All Clear",
+ systemImage: "checkmark.circle",
+ description: Text("There are no assigned tickets or recent builds right now.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ }
+
+ @ViewBuilder
+ private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionLoadingRow(label: "Loading assigned tickets")
+ } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load assigned tickets.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ } else {
+ ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Assigned Tickets") {
+ HomeAssignedTicketsListView(viewModel: viewModel)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
+ HomeSectionLoadingRow(label: "Loading recent builds")
+ } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load recent builds.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "No recent builds.",
+ systemImage: "clock"
+ )
+ } else {
+ ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
+ NavigationLink {
+ BuildDetailView(jobId: build.job.id)
+ } label: {
+ HomeBuildRow(build: build)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Recent Builds") {
+ BuildListView()
+ }
+ }
+ }
+
+}
+
+private struct HomeBuildRow: View {
+ let build: HomeBuildItem
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: build.job.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text("Job #\(build.job.id)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.status.rawValue.capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Spacer()
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let repositoryDisplayName = build.repositoryDisplayName {
+ return repositoryDisplayName
+ }
+ return build.job.displayLabel
+ }
+}
+
+private struct HomeAssignedTicketRow: View {
+ let ticket: HomeAssignedTicket
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ TicketStatusIcon(status: ticket.ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(ticket.ticket.title)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer(minLength: 8)
+
+ Text(ticket.ticket.status.displayName)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .fixedSize()
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeSectionLoadingRow: View {
+ let label: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text(label)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct HomeSectionHeader<Destination: View>: View {
+ let title: String
+ let destination: Destination
+
+ init(_ title: String, @ViewBuilder destination: () -> Destination) {
+ self.title = title
+ self.destination = destination()
+ }
+
+ var body: some View {
+ HStack {
+ Text(title)
+ Spacer()
+ NavigationLink {
+ destination
+ } label: {
+ Text("See All")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.plain)
+ }
+ .textCase(nil)
+ }
+}
+
+private struct HomeAssignedTicketsListView: View {
+ let viewModel: HomeViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.assignedTickets) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+
+ if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ }
+ }
+ .navigationTitle("Assigned Tickets")
+ .navigationBarTitleDisplayMode(.inline)
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ .overlay {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading assigned tickets…")
+ }
+ }
+ }
+}
+
+private struct HomeSectionMessageRow: View {
+ let text: String
+ let systemImage: String
+ var emphasized = false
+ var accessibilityHint: String? = nil
+
+ var body: some View {
+ Label(text, systemImage: systemImage)
+ .font(.subheadline)
+ .foregroundStyle(emphasized ? .secondary : .tertiary)
+ .accessibilityHint(accessibilityHint ?? "")
+ }
+}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
new file mode 100644
index 0000000..ce27bb0
--- /dev/null
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -0,0 +1,443 @@
+import Foundation
+
+private struct HomeJobsResponse: Decodable, Sendable {
+ let jobs: HomeJobsPage
+}
+
+private struct HomeJobsPage: Decodable, Sendable {
+ let results: [HomeJobPayload]
+}
+
+private struct HomeTrackersResponse: Decodable, Sendable {
+ let trackers: HomeTrackersPage
+}
+
+private struct HomeTrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+}
+
+private struct HomeTrackerTicketsResponse: Decodable, Sendable {
+ let user: HomeTrackerTicketsUser
+}
+
+private struct HomeTrackerTicketsUser: Decodable, Sendable {
+ let tracker: HomeTrackerTicketsTracker
+}
+
+private struct HomeTrackerTicketsTracker: Decodable, Sendable {
+ let tickets: HomeTrackerTicketsPage
+}
+
+private struct HomeTrackerTicketsPage: Decodable, Sendable {
+ let results: [HomeTicketPayload]
+}
+
+private struct HomeTicketPayload: Decodable, Sendable {
+ let id: Int
+ let title: String
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let created: Date
+ let submitter: Entity
+ let labels: [TicketLabel]
+ let assignees: [Entity]
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case title = "subject"
+ case status
+ case resolution
+ case created
+ case submitter
+ case labels
+ case assignees
+ }
+
+ var ticketSummary: TicketSummary {
+ TicketSummary(
+ id: id,
+ title: title,
+ status: status,
+ resolution: resolution,
+ created: created,
+ submitter: submitter,
+ labels: labels,
+ assignees: assignees
+ )
+ }
+}
+
+struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
+ let trackerId: Int
+ let trackerRid: String
+ let trackerName: String
+ let ownerCanonicalName: String
+ let ticket: TicketSummary
+
+ var id: String {
+ "\(trackerRid)#\(ticket.id)"
+ }
+
+ var ownerUsername: String {
+ if ownerCanonicalName.hasPrefix("~") {
+ return String(ownerCanonicalName.dropFirst())
+ }
+ return ownerCanonicalName
+ }
+}
+
+struct HomeBuildItem: Identifiable, Hashable, Sendable {
+ let job: JobSummary
+ let repositoryName: String?
+ let repositoryOwner: String?
+
+ var id: Int { job.id }
+
+ var repositoryDisplayName: String? {
+ guard let repositoryName else { return nil }
+ if let repositoryOwner {
+ return "\(repositoryOwner)/\(repositoryName)"
+ }
+ return repositoryName
+ }
+}
+
+@Observable
+@MainActor
+final class HomeViewModel {
+ private(set) var failedBuilds: [HomeBuildItem] = []
+ private(set) var assignedTickets: [HomeAssignedTicket] = []
+ private(set) var recentBuilds: [HomeBuildItem] = []
+ private(set) var isLoadingFailedBuilds = false
+ private(set) var isLoadingAssignedTickets = false
+ private(set) var isLoadingRecentBuilds = false
+ private(set) var failedBuildsError: String?
+ private(set) var assignedTicketsError: String?
+ private(set) var recentBuildsError: String?
+
+ private let currentUser: User
+ private let client: SRHTClient
+ private let ticketFetchConcurrencyLimit = 6
+
+ private static let jobsQuery = """
+ query jobs {
+ jobs {
+ results {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ tasks { name status }
+ manifest
+ }
+ }
+ }
+ """
+
+ private static let trackersQuery = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let trackerTicketsQuery = """
+ query tickets($owner: String!, $tracker: String!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets {
+ results {
+ id
+ subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ init(currentUser: User, client: SRHTClient) {
+ self.currentUser = currentUser
+ self.client = client
+ }
+
+ func loadDashboard() async {
+ isLoadingFailedBuilds = true
+ isLoadingAssignedTickets = true
+ isLoadingRecentBuilds = true
+ failedBuildsError = nil
+ assignedTicketsError = nil
+ recentBuildsError = nil
+
+ async let jobsTask = loadRecentJobs()
+ async let assignedTicketsTask = loadAssignedTickets()
+
+ let recentJobsResult = await jobsTask
+
+ switch recentJobsResult {
+ case .success(let recentJobs):
+ let buildItems = Self.buildItems(from: recentJobs)
+ self.recentBuilds = buildItems
+ self.failedBuilds = Self.failedBuilds(from: buildItems)
+ self.failedBuildsError = nil
+ self.recentBuildsError = nil
+ case .failure(let error):
+ self.recentBuilds = []
+ self.failedBuilds = []
+ self.failedBuildsError = error.localizedDescription
+ self.recentBuildsError = error.localizedDescription
+ }
+ isLoadingFailedBuilds = false
+ isLoadingRecentBuilds = false
+
+ let assignedTicketsResult = await assignedTicketsTask
+
+ switch assignedTicketsResult {
+ case .success(let assignedTickets):
+ self.assignedTickets = assignedTickets
+ self.assignedTicketsError = nil
+ case .failure(let error):
+ self.assignedTickets = []
+ self.assignedTicketsError = error.localizedDescription
+ }
+ isLoadingAssignedTickets = false
+ }
+
+ private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
+ do {
+ let response = try await client.execute(
+ service: .builds,
+ query: Self.jobsQuery,
+ responseType: HomeJobsResponse.self
+ )
+ return .success(response.jobs.results)
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
+ do {
+ let trackers = try await fetchAllTrackers()
+ let tickets = try await fetchAssignedTickets(for: trackers)
+ .sorted { $0.ticket.created > $1.ticket.created }
+ return .success(tickets)
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func fetchAllTrackers() async throws -> [TrackerSummary] {
+ var allTrackers: [TrackerSummary] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackersQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: HomeTrackersResponse.self
+ )
+
+ allTrackers.append(contentsOf: response.trackers.results)
+ guard let nextCursor = response.trackers.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return allTrackers
+ }
+
+ private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
+ guard !trackers.isEmpty else { return [] }
+
+ var assignedTickets: [HomeAssignedTicket] = []
+ var startIndex = trackers.startIndex
+
+ while startIndex < trackers.endIndex {
+ let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
+ let batch = Array(trackers[startIndex..<endIndex])
+
+ let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
+ for tracker in batch {
+ group.addTask {
+ try await self.fetchAssignedTickets(for: tracker)
+ }
+ }
+
+ var ticketsForBatch: [HomeAssignedTicket] = []
+ for try await tickets in group {
+ ticketsForBatch.append(contentsOf: tickets)
+ }
+ return ticketsForBatch
+ }
+
+ assignedTickets.append(contentsOf: batchTickets)
+ startIndex = endIndex
+ }
+
+ return assignedTickets
+ }
+
+ private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackerTicketsQuery,
+ variables: [
+ "owner": tracker.owner.canonicalName.hasPrefix("~")
+ ? String(tracker.owner.canonicalName.dropFirst())
+ : tracker.owner.canonicalName,
+ "tracker": tracker.name
+ ],
+ responseType: HomeTrackerTicketsResponse.self
+ )
+
+ return response.user.tracker.tickets.results.compactMap { payload in
+ guard payload.status.isOpen else {
+ return nil
+ }
+ guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
+ return nil
+ }
+
+ return HomeAssignedTicket(
+ trackerId: tracker.id,
+ trackerRid: tracker.rid,
+ trackerName: tracker.name,
+ ownerCanonicalName: tracker.owner.canonicalName,
+ ticket: payload.ticketSummary
+ )
+ }
+ }
+
+ nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
+ jobs.map { job in
+ let repository = primaryRepositoryReference(in: job.manifest)
+ return HomeBuildItem(
+ job: job.jobSummary,
+ repositoryName: repository?.name,
+ repositoryOwner: repository?.ownerCanonicalName
+ )
+ }
+ }
+
+ nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] {
+ builds.filter { build in
+ switch build.job.status {
+ case .failed, .timeout:
+ true
+ default:
+ false
+ }
+ }
+ }
+
+ nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
+ failedBuilds(from: buildItems(from: jobs))
+ }
+
+ nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
+ let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+ let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
+ if assigneeCanonical == currentCanonical {
+ return true
+ }
+
+ let assigneeUsername = normalizedUsername(entity.canonicalName)
+ let currentUsername = normalizedUsername(currentUser.username)
+ return assigneeUsername == currentUsername
+ }
+
+ nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
+ guard let manifest else { return nil }
+ let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+ return nil
+ }
+ let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
+ guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
+ let ownerRange = Range(match.range(at: 1), in: manifest),
+ let nameRange = Range(match.range(at: 2), in: manifest) else {
+ return nil
+ }
+
+ let owner = String(manifest[ownerRange])
+ var name = String(manifest[nameRange])
+ if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
+ name.removeSubrange(suffixRange)
+ }
+ name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard !name.isEmpty else { return nil }
+ return (owner, name)
+ }
+
+ private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+ if trimmed.hasPrefix("~") {
+ return trimmed
+ }
+ return "~\(trimmed)"
+ }
+
+ private nonisolated static func normalizedUsername(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("~") {
+ return String(trimmed.dropFirst())
+ }
+ return trimmed
+ }
+}
+struct HomeJobPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let tasks: [JobTaskSummary]
+ let manifest: String?
+
+ nonisolated var jobSummary: JobSummary {
+ JobSummary(
+ id: id,
+ created: created,
+ updated: updated,
+ status: status,
+ note: note,
+ tags: tags,
+ visibility: visibility,
+ image: image,
+ tasks: tasks
+ )
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index 6fcfafa..7cac8a8 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -63,7 +63,10 @@ struct RepositoryListView: View {
List {
ForEach(viewModel.repositories) { repo in
NavigationLink(value: repo) {
- RepositoryRowView(repository: repo)
+ RepositoryRowView(
+ repository: repo,
+ buildStatus: viewModel.latestBuildStatus(for: repo)
+ )
}
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
.task {
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 6b63203..5bdc4a5 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
final class RepositoryListViewModel {
private(set) var repositories: [RepositorySummary] = []
+ private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var isRefreshing = false
@@ -41,9 +42,11 @@ final class RepositoryListViewModel {
private(set) var hasLoadedSearchIndex = false
private var searchIndex: [RepositorySummary] = []
private let client: SRHTClient
+ private var buildStatusTask: Task<Void, Never>?
private static let gitCacheKey = "git.repositories"
private static let hgCacheKey = "hg.repositories"
+ private static let buildsCacheKey = "builds.repository-status"
private static let minimumRemoteSearchLength = 3
init(client: SRHTClient) {
@@ -117,6 +120,20 @@ final class RepositoryListViewModel {
}
"""
+ private static let buildsQuery = """
+ query jobs($cursor: Cursor) {
+ jobs(cursor: $cursor) {
+ results {
+ id
+ created
+ status
+ manifest
+ }
+ cursor
+ }
+ }
+ """
+
// MARK: - Public API
/// Fetch the first page of repositories. Shows cached data instantly if available,
@@ -168,6 +185,7 @@ final class RepositoryListViewModel {
}
repositories = filteredResults.sorted(by: repositorySortOrder)
+ scheduleBuildStatusRefresh()
} catch {
// Only show error if we have no cached data to fall back on
if repositories.isEmpty {
@@ -245,6 +263,7 @@ final class RepositoryListViewModel {
}
repositories.insert(repository, at: 0)
insertIntoSearchIndex(repository)
+ scheduleBuildStatusRefresh()
return repository
} catch {
self.error = repositoryCreationErrorMessage(for: error)
@@ -306,6 +325,22 @@ final class RepositoryListViewModel {
let createRepository: HGRepositoryPayload
}
+ private struct BuildJobsResponse: Decodable, Sendable {
+ let jobs: BuildJobsPage
+ }
+
+ private struct BuildJobsPage: Decodable, Sendable {
+ let results: [BuildStatusPayload]
+ let cursor: String?
+ }
+
+ private struct BuildStatusPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let status: JobStatus
+ let manifest: String?
+ }
+
private struct HGPage: Decodable, Sendable {
let results: [HGRepositoryPayload]
let cursor: String?
@@ -380,6 +415,10 @@ final class RepositoryListViewModel {
searchIndex
}
+ func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
+ latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
+ }
+
private func fetchPage(
service: SRHTService,
cursor: String?,
@@ -514,9 +553,94 @@ final class RepositoryListViewModel {
let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
repositories = sortedRepositories
updateSearchIndex(with: sortedRepositories)
+ scheduleBuildStatusRefresh()
}
}
+ private func scheduleBuildStatusRefresh() {
+ let repositoriesSnapshot = repositories
+ buildStatusTask?.cancel()
+ buildStatusTask = Task { [weak self] in
+ guard let self else { return }
+ await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
+ }
+ }
+
+ private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
+ let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
+ guard !targetKeys.isEmpty else {
+ await MainActor.run {
+ latestBuildStatuses = [:]
+ }
+ return
+ }
+
+ var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
+ var cursor: String?
+ var shouldUseCache = true
+
+ do {
+ while !Task.isCancelled {
+ let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
+ shouldUseCache = false
+
+ for job in page.results {
+ let jobStatus = Self.repositoryBuildStatus(for: job.status)
+ guard let manifest = job.manifest else { continue }
+
+ for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
+ let existing = resolvedStatuses[key]
+ if existing == nil || existing!.0 < job.created {
+ resolvedStatuses[key] = (job.created, jobStatus)
+ }
+ }
+ }
+
+ if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
+ break
+ }
+ cursor = page.cursor
+ }
+
+ let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
+ result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
+ }
+
+ await MainActor.run {
+ guard repositories == self.repositories else { return }
+ latestBuildStatuses = finalStatuses
+ }
+ } catch {
+ // Build status is auxiliary data for the list. Leave the default gray state on failure.
+ }
+ }
+
+ private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ if useCache && cursor == nil {
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.buildsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: BuildJobsResponse.self,
+ cacheKey: Self.buildsCacheKey
+ )
+ return result.jobs
+ }
+
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.buildsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: BuildJobsResponse.self
+ )
+ return result.jobs
+ }
+
private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
var allRepositories: [RepositorySummary] = []
var currentCursor: String? = nil
@@ -580,6 +704,67 @@ final class RepositoryListViewModel {
repo.description?.lowercased().contains(lowercasedQuery) ?? false
}
}
+
+ nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
+ buildStatusCacheKey(
+ service: repository.service,
+ ownerCanonicalName: repository.owner.canonicalName,
+ repositoryName: repository.name
+ )
+ }
+
+ nonisolated static func buildStatusCacheKey(
+ service: SRHTService,
+ ownerCanonicalName: String,
+ repositoryName: String
+ ) -> String {
+ "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
+ }
+
+ nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
+ switch jobStatus {
+ case .success:
+ .success
+ case .pending, .queued, .running:
+ .running
+ case .failed, .cancelled, .timeout:
+ .failed
+ }
+ }
+
+ nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
+ let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+ return []
+ }
+
+ let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
+ return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
+ guard
+ let serviceRange = Range(match.range(at: 1), in: manifest),
+ let ownerRange = Range(match.range(at: 2), in: manifest),
+ let nameRange = Range(match.range(at: 3), in: manifest)
+ else {
+ return
+ }
+
+ let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
+ let owner = String(manifest[ownerRange]).lowercased()
+ var name = String(manifest[nameRange]).lowercased()
+
+ if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
+ name.removeSubrange(suffixRange)
+ }
+ name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ if !name.isEmpty {
+ result.insert(buildStatusCacheKey(
+ service: service,
+ ownerCanonicalName: owner,
+ repositoryName: name
+ ))
+ }
+ }
+ }
}
private extension Array {
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index 9f8cd15..f8fd205 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct RepositoryRowView: View {
let repository: RepositorySummary
+ let buildStatus: RepositoryBuildStatus
var body: some View {
VStack(alignment: .leading, spacing: 4) {
@@ -20,6 +21,9 @@ struct RepositoryRowView: View {
.foregroundStyle(.cyan)
}
+ if buildStatus != .none {
+ RepositoryBuildStatusIndicator(status: buildStatus)
+ }
VisibilityBadge(visibility: repository.visibility)
}
@@ -53,6 +57,47 @@ struct RepositoryRowView: View {
}
}
+private struct RepositoryBuildStatusIndicator: View {
+ let status: RepositoryBuildStatus
+
+ var body: some View {
+ Circle()
+ .fill(color)
+ .frame(width: 8, height: 8)
+ .overlay {
+ Circle()
+ .strokeBorder(.primary.opacity(0.08))
+ }
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ private var color: Color {
+ switch status {
+ case .success:
+ .green
+ case .failed:
+ .red
+ case .running:
+ .orange
+ case .none:
+ .clear
+ }
+ }
+
+ private var accessibilityLabel: String {
+ switch status {
+ case .success:
+ "Latest build succeeded"
+ case .failed:
+ "Latest build failed"
+ case .running:
+ "Latest build is running"
+ case .none:
+ "No recent builds"
+ }
+ }
+}
+
// MARK: - VisibilityBadge
struct VisibilityBadge: View {
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index a2cf699..7861b40 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -1,5 +1,12 @@
import SwiftUI
+enum RepositoryBuildStatus: Sendable {
+ case success
+ case failed
+ case running
+ case none
+}
+
struct RepositoryCloneURLs {
let readOnly: String
let readWrite: String