Commit 1c32074b4f

1c32074b4f3bdd992fd40a65301ed3f821d0cebb

parent: e137533ecf

Unsigned

cmc <hello@cleberg.net> · 2026-04-20 17:51 UTC

fix: ensure all branches and tags are accounted for and not cutoff due to pagination

Layout: unified · split

Hutch/Views/Repositories/ReferencesListView.swift +63 −8
@@ -5,21 +5,39 @@ struct ReferencesListView: View {
5 5
6 var body: some View { 6 var body: some View {
7 List { 7 List {
8 if !viewModel.branches.isEmpty { 8 if let defaultBranch = viewModel.defaultBranchReference {
9 Section("Branches") { 9 Section {
10 ForEach(viewModel.branches, id: \.name) { ref in 10 ReferenceRow(reference: defaultBranch, prefix: "refs/heads/")
11 ReferenceRow(reference: ref, prefix: "refs/heads/") 11 .themedRow()
12
13 NavigationLink("See All Branches") {
14 ReferencesDetailListView(
15 title: "Branches",
16 references: viewModel.branches,
17 prefix: "refs/heads/"
18 )
12 } 19 }
13 .themedRow() 20 .themedRow()
21 } header: {
22 sectionHeader(title: "Default Branch")
14 } 23 }
15 } 24 }
16 25
17 if !viewModel.tags.isEmpty { 26 if let latestTag = viewModel.latestTagReference {
18 Section("Tags") { 27 Section {
19 ForEach(viewModel.tags, id: \.name) { ref in 28 ReferenceRow(reference: latestTag, prefix: "refs/tags/")
20 ReferenceRow(reference: ref, prefix: "refs/tags/") 29 .themedRow()
30
31 NavigationLink("See All Tags") {
32 ReferencesDetailListView(
33 title: "Tags",
34 references: viewModel.tags,
35 prefix: "refs/tags/"
36 )
21 } 37 }
22 .themedRow() 38 .themedRow()
39 } header: {
40 sectionHeader(title: "Latest Tag")
23 } 41 }
24 } 42 }
25 } 43 }
@@ -51,6 +69,41 @@ struct ReferencesListView: View {
51 await viewModel.loadReferences() 69 await viewModel.loadReferences()
52 } 70 }
53 } 71 }
72
73 @ViewBuilder
74 private func sectionHeader(title: String) -> some View {
75 HStack {
76 Text(title)
77 Spacer()
78 }
79 }
80}
81
82private struct ReferencesDetailListView: View {
83 let title: String
84 let references: [ReferenceDetail]
85 let prefix: String
86
87 var body: some View {
88 List {
89 ForEach(references, id: \.name) { reference in
90 ReferenceRow(reference: reference, prefix: prefix)
91 .themedRow()
92 }
93 }
94 .themedList()
95 .listStyle(.insetGrouped)
96 .navigationTitle(title)
97 .overlay {
98 if references.isEmpty {
99 ContentUnavailableView(
100 "No \(title)",
101 systemImage: prefix.contains("tags") ? "tag" : "arrow.triangle.branch",
102 description: Text("This repository does not have any \(title.lowercased()).")
103 )
104 }
105 }
106 }
54} 107}
55 108
56private struct ReferenceRow: View { 109private struct ReferenceRow: View {
@@ -80,6 +133,8 @@ private struct ReferenceRow: View {
80 .font(.caption.monospaced()) 133 .font(.caption.monospaced())
81 .foregroundStyle(.secondary) 134 .foregroundStyle(.secondary)
82 } 135 }
136 .frame(maxWidth: .infinity, alignment: .leading)
137 .contentShape(Rectangle())
83 } 138 }
84 139
85 private var shortName: String { 140 private var shortName: String {
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +69 −11
@@ -240,9 +240,9 @@ final class RepositoryDetailViewModel {
240 // MARK: - References 240 // MARK: - References
241 241
242 private static let refsQuery = """ 242 private static let refsQuery = """
243 query refs($rid: ID!) { 243 query refs($rid: ID!, $cursor: Cursor) {
244 repository(rid: $rid) { 244 repository(rid: $rid) {
245 references { 245 references(cursor: $cursor) {
246 results { 246 results {
247 name 247 name
248 target 248 target
@@ -263,15 +263,15 @@ final class RepositoryDetailViewModel {
263 error = nil 263 error = nil
264 264
265 do { 265 do {
266 let result = try await client.execute( 266 let allRefs = try await fetchAllReferences()
267 service: service, 267 branches = allRefs
268 query: Self.refsQuery, 268 .filter { $0.name.hasPrefix("refs/heads/") }
269 variables: ["rid": repository.rid], 269 .map { $0.toDetail() }
270 responseType: RefsResponse.self 270 .sorted(by: Self.sortBranches)
271 ) 271 tags = allRefs
272 let allRefs = result.repository?.references.results ?? [] 272 .filter { $0.name.hasPrefix("refs/tags/") }
273 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() } 273 .map { $0.toDetail() }
274 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() } 274 .sorted(by: Self.sortTags)
275 } catch { 275 } catch {
276 if isEmptyRepositoryError(error) { 276 if isEmptyRepositoryError(error) {
277 branches = [] 277 branches = []
@@ -284,6 +284,64 @@ final class RepositoryDetailViewModel {
284 isLoadingRefs = false 284 isLoadingRefs = false
285 } 285 }
286 286
287 var defaultBranchReference: ReferenceDetail? {
288 if let headName = repository.head?.name,
289 let branch = branches.first(where: { $0.name == headName }) {
290 return branch
291 }
292 return branches.first
293 }
294
295 var latestTagReference: ReferenceDetail? {
296 tags.first
297 }
298
299 private func fetchAllReferences() async throws -> [ReferencePayload] {
300 var allRefs: [ReferencePayload] = []
301 var cursor: String?
302
303 repeat {
304 let page = try await fetchReferencePage(cursor: cursor)
305 allRefs.append(contentsOf: page.results)
306 cursor = page.cursor
307 } while cursor != nil
308
309 return allRefs
310 }
311
312 private func fetchReferencePage(cursor: String?) async throws -> RefsPage {
313 var variables: [String: any Sendable] = ["rid": repository.rid]
314 if let cursor {
315 variables["cursor"] = cursor
316 }
317
318 let result = try await client.execute(
319 service: service,
320 query: Self.refsQuery,
321 variables: variables,
322 responseType: RefsResponse.self
323 )
324
325 return result.repository?.references ?? RefsPage(results: [], cursor: nil)
326 }
327
328 nonisolated private static func sortBranches(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
329 lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
330 }
331
332 nonisolated private static func sortTags(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
333 switch (lhs.date, rhs.date) {
334 case let (lhsDate?, rhsDate?) where lhsDate != rhsDate:
335 return lhsDate > rhsDate
336 case (.some, nil):
337 return true
338 case (nil, .some):
339 return false
340 default:
341 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
342 }
343 }
344
287 // MARK: - README 345 // MARK: - README
288 346
289 private static let readmeQuery = """ 347 private static let readmeQuery = """