From d29fbef60abdffaee190b758530dde9212745ed2 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Apr 2026 12:51:22 -0500 Subject: fix: ensure all branches and tags are accounted for and not cutoff due to pagination --- Hutch/Views/Repositories/ReferencesListView.swift | 71 ++++++++++++++++--- .../Repositories/RepositoryDetailViewModel.swift | 80 +++++++++++++++++++--- 2 files changed, 132 insertions(+), 19 deletions(-) (limited to 'Hutch/Views/Repositories') diff --git a/Hutch/Views/Repositories/ReferencesListView.swift b/Hutch/Views/Repositories/ReferencesListView.swift index 0e3be0a..a2eed11 100644 --- a/Hutch/Views/Repositories/ReferencesListView.swift +++ b/Hutch/Views/Repositories/ReferencesListView.swift @@ -5,21 +5,39 @@ struct ReferencesListView: View { var body: some View { List { - if !viewModel.branches.isEmpty { - Section("Branches") { - ForEach(viewModel.branches, id: \.name) { ref in - ReferenceRow(reference: ref, prefix: "refs/heads/") + if let defaultBranch = viewModel.defaultBranchReference { + Section { + ReferenceRow(reference: defaultBranch, prefix: "refs/heads/") + .themedRow() + + NavigationLink("See All Branches") { + ReferencesDetailListView( + title: "Branches", + references: viewModel.branches, + prefix: "refs/heads/" + ) } .themedRow() + } header: { + sectionHeader(title: "Default Branch") } } - if !viewModel.tags.isEmpty { - Section("Tags") { - ForEach(viewModel.tags, id: \.name) { ref in - ReferenceRow(reference: ref, prefix: "refs/tags/") + if let latestTag = viewModel.latestTagReference { + Section { + ReferenceRow(reference: latestTag, prefix: "refs/tags/") + .themedRow() + + NavigationLink("See All Tags") { + ReferencesDetailListView( + title: "Tags", + references: viewModel.tags, + prefix: "refs/tags/" + ) } .themedRow() + } header: { + sectionHeader(title: "Latest Tag") } } } @@ -51,6 +69,41 @@ struct ReferencesListView: View { await viewModel.loadReferences() } } + + @ViewBuilder + private func sectionHeader(title: String) -> some View { + HStack { + Text(title) + Spacer() + } + } +} + +private struct ReferencesDetailListView: View { + let title: String + let references: [ReferenceDetail] + let prefix: String + + var body: some View { + List { + ForEach(references, id: \.name) { reference in + ReferenceRow(reference: reference, prefix: prefix) + .themedRow() + } + } + .themedList() + .listStyle(.insetGrouped) + .navigationTitle(title) + .overlay { + if references.isEmpty { + ContentUnavailableView( + "No \(title)", + systemImage: prefix.contains("tags") ? "tag" : "arrow.triangle.branch", + description: Text("This repository does not have any \(title.lowercased()).") + ) + } + } + } } private struct ReferenceRow: View { @@ -80,6 +133,8 @@ private struct ReferenceRow: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } private var shortName: String { diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 8dec51b..9aff1a4 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -240,9 +240,9 @@ final class RepositoryDetailViewModel { // MARK: - References private static let refsQuery = """ - query refs($rid: ID!) { + query refs($rid: ID!, $cursor: Cursor) { repository(rid: $rid) { - references { + references(cursor: $cursor) { results { name target @@ -263,15 +263,15 @@ final class RepositoryDetailViewModel { error = nil do { - let result = try await client.execute( - service: service, - query: Self.refsQuery, - variables: ["rid": repository.rid], - responseType: RefsResponse.self - ) - let allRefs = result.repository?.references.results ?? [] - branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() } - tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() } + let allRefs = try await fetchAllReferences() + branches = allRefs + .filter { $0.name.hasPrefix("refs/heads/") } + .map { $0.toDetail() } + .sorted(by: Self.sortBranches) + tags = allRefs + .filter { $0.name.hasPrefix("refs/tags/") } + .map { $0.toDetail() } + .sorted(by: Self.sortTags) } catch { if isEmptyRepositoryError(error) { branches = [] @@ -284,6 +284,64 @@ final class RepositoryDetailViewModel { isLoadingRefs = false } + var defaultBranchReference: ReferenceDetail? { + if let headName = repository.head?.name, + let branch = branches.first(where: { $0.name == headName }) { + return branch + } + return branches.first + } + + var latestTagReference: ReferenceDetail? { + tags.first + } + + private func fetchAllReferences() async throws -> [ReferencePayload] { + var allRefs: [ReferencePayload] = [] + var cursor: String? + + repeat { + let page = try await fetchReferencePage(cursor: cursor) + allRefs.append(contentsOf: page.results) + cursor = page.cursor + } while cursor != nil + + return allRefs + } + + private func fetchReferencePage(cursor: String?) async throws -> RefsPage { + var variables: [String: any Sendable] = ["rid": repository.rid] + if let cursor { + variables["cursor"] = cursor + } + + let result = try await client.execute( + service: service, + query: Self.refsQuery, + variables: variables, + responseType: RefsResponse.self + ) + + return result.repository?.references ?? RefsPage(results: [], cursor: nil) + } + + nonisolated private static func sortBranches(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool { + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + nonisolated private static func sortTags(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool { + switch (lhs.date, rhs.date) { + case let (lhsDate?, rhsDate?) where lhsDate != rhsDate: + return lhsDate > rhsDate + case (.some, nil): + return true + case (nil, .some): + return false + default: + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + // MARK: - README private static let readmeQuery = """ -- cgit v1.2.3