summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/DiffView.swift157
-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
5 files changed, 391 insertions, 8 deletions
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index b1db464..4b8e512 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -9,14 +9,158 @@ struct DiffView: View {
let diff: String
var body: some View {
- let lines = diff.components(separatedBy: "\n")
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(fileSections) { section in
+ DiffFileSectionView(section: section)
+ }
+ }
+ }
+
+ private var fileSections: [DiffFileSection] {
+ DiffFileSection.parse(from: normalizedDiff)
+ }
+
+ private var normalizedDiff: String {
+ diff
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ }
+}
+
+private struct DiffFileSectionView: View {
+ let section: DiffFileSection
+ @State private var isExpanded = true
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Button {
+ isExpanded.toggle()
+ } label: {
+ HStack(spacing: 10) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 12)
+
+ Text(section.filename)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
- LazyVStack(alignment: .leading, spacing: 0) {
+ Spacer(minLength: 8)
+
+ Text(section.changeSummary)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(Color(.tertiarySystemBackground))
+
+ if isExpanded {
+ DiffBlockView(lines: section.lines)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.06))
+ }
+ }
+}
+
+private struct DiffBlockView: View {
+ let lines: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
DiffLineView(line: line)
}
}
- .font(.caption.monospaced())
+ .font(.system(.caption, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(.secondarySystemBackground))
+ }
+}
+
+private struct DiffFileSection: Identifiable {
+ let id: String
+ let filename: String
+ let lines: [String]
+ let additions: Int
+ let deletions: Int
+
+ var changeSummary: String {
+ "+\(additions) -\(deletions)"
+ }
+
+ static func parse(from diff: String) -> [DiffFileSection] {
+ let lines = diff.components(separatedBy: "\n")
+ guard !lines.isEmpty else { return [] }
+
+ let boundaries = lines.enumerated().compactMap { index, line in
+ line.hasPrefix("diff --git ") ? index : nil
+ }
+
+ guard !boundaries.isEmpty else {
+ let section = makeSection(lines: lines, fallbackIndex: 0)
+ return section.lines.isEmpty ? [] : [section]
+ }
+
+ var sections: [DiffFileSection] = []
+ for (position, startIndex) in boundaries.enumerated() {
+ let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
+ let sectionLines = Array(lines[startIndex..<endIndex])
+ let section = makeSection(lines: sectionLines, fallbackIndex: position)
+ if !section.lines.isEmpty {
+ sections.append(section)
+ }
+ }
+ return sections
+ }
+
+ private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
+ let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
+ let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
+ let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
+ return DiffFileSection(
+ id: "\(fallbackIndex)-\(filename)",
+ filename: filename,
+ lines: lines,
+ additions: additions,
+ deletions: deletions
+ )
+ }
+
+ private static func fileName(from lines: [String]) -> String? {
+ if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
+ let parts = diffHeader.split(separator: " ")
+ if let rhs = parts.last, rhs.hasPrefix("b/") {
+ return String(rhs.dropFirst(2))
+ }
+ }
+
+ if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
+ let path = String(plusHeader.dropFirst(4))
+ if path.hasPrefix("b/") {
+ return String(path.dropFirst(2))
+ }
+ return path
+ }
+
+ if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
+ let path = String(minusHeader.dropFirst(4))
+ if path.hasPrefix("a/") {
+ return String(path.dropFirst(2))
+ }
+ return path
+ }
+
+ return nil
}
}
@@ -27,7 +171,6 @@ private struct DiffLineView: View {
Text(line.isEmpty ? " " : line)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
- .padding(.vertical, 1)
.background(backgroundColor)
.foregroundStyle(foregroundColor)
.fontWeight(isHeader ? .semibold : .regular)
@@ -47,9 +190,9 @@ private struct DiffLineView: View {
switch kind {
case .added: .green.opacity(0.15)
case .removed: .red.opacity(0.15)
- case .hunk: .gray.opacity(0.12)
- case .fileHeader: .gray.opacity(0.08)
- case .meta: .gray.opacity(0.05)
+ case .hunk: .clear
+ case .fileHeader: .clear
+ case .meta: .clear
case .context: .clear
}
}
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