diff options
Diffstat (limited to 'Hutch/Views/Repositories')
| -rw-r--r-- | Hutch/Views/Repositories/DiffView.swift | 157 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListView.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 185 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryRowView.swift | 45 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySummarySupport.swift | 7 |
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 |
