krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.0: Hutch/Views/Repositories/RepositoryDetailViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct LogResponse: Decodable, Sendable {
6 let repository: LogRepository?
7}
8
9private struct LogRepository: Decodable, Sendable {
10 let log: LogPage
11}
12
13private struct LogPage: Decodable, Sendable {
14 let results: [CommitSummary]
15 let cursor: String?
16}
17
18private struct RefsResponse: Decodable, Sendable {
19 let repository: RefsRepository?
20}
21
22private struct RefsRepository: Decodable, Sendable {
23 let references: RefsPage
24}
25
26private struct RefsPage: Decodable, Sendable {
27 let results: [ReferencePayload]
28 let cursor: String?
29}
30
31/// Decodes a single reference including the optional follow object for date extraction.
32private struct ReferencePayload: Decodable, Sendable {
33 let name: String
34 let target: String?
35 let follow: ReferenceFollow?
36
37 func toDetail() -> ReferenceDetail {
38 // Branches and lightweight tags follow to a Commit (author.time).
39 // Annotated tags follow to a Tag object (tagger.time).
40 let date = follow?.author?.time ?? follow?.tagger?.time
41 return ReferenceDetail(name: name, target: target, date: date)
42 }
43}
44
45private struct ReferenceFollow: Decodable, Sendable {
46 let author: ReferenceSignature?
47 let tagger: ReferenceSignature?
48}
49
50private struct ReferenceSignature: Decodable, Sendable {
51 let time: Date
52}
53
54private struct ReadmeResponse: Decodable, Sendable {
55 let repository: ReadmeRepository?
56}
57
58private struct ReadmeRepository: Decodable, Sendable {
59 let readme: String?
60}
61
62private struct PathResponse: Decodable, Sendable {
63 let repository: PathRepository?
64}
65
66private struct PathRepository: Decodable, Sendable {
67 let readme: PathEntry?
68}
69
70private struct PathEntry: Decodable, Sendable {
71 let object: PathObject?
72}
73
74private struct PathObject: Decodable, Sendable {
75 let text: String?
76}
77
78private struct ArtifactsResponse: Decodable, Sendable {
79 let repository: ArtifactsRepository?
80}
81
82private struct ArtifactsRepository: Decodable, Sendable {
83 let references: ArtifactRefsPage
84}
85
86private struct ArtifactRefsPage: Decodable, Sendable {
87 let results: [ArtifactRef]
88 let cursor: String?
89}
90
91private struct ArtifactRef: Decodable, Sendable {
92 let name: String
93 let artifacts: ArtifactPage
94}
95
96// MARK: - View Model
97
98@Observable
99@MainActor
100final class RepositoryDetailViewModel {
101
102 enum Tab: String, CaseIterable {
103 case summary = "Summary"
104 case tree = "Tree"
105 case log = "Log"
106 case refs = "Refs"
107 case artifacts = "Artifacts"
108 }
109
110 let repository: RepositorySummary
111 private var service: SRHTService { repository.service }
112 private let client: SRHTClient
113
114 // MARK: - Commit log state
115
116 private(set) var commits: [CommitSummary] = []
117 private(set) var isLoadingCommits = false
118 private(set) var isLoadingMoreCommits = false
119 private var commitCursor: String?
120 private var hasMoreCommits = true
121
122 // MARK: - References state
123
124 private(set) var branches: [ReferenceDetail] = []
125 private(set) var tags: [ReferenceDetail] = []
126 private(set) var isLoadingRefs = false
127
128 // MARK: - README state
129
130 enum ReadmeContent {
131 case html(String)
132 case markdown(String)
133 case org(String)
134 case plainText(String)
135 }
136
137 private(set) var readmeContent: ReadmeContent?
138 private(set) var readmePath: String?
139 private(set) var isLoadingReadme = false
140 private(set) var readmeLoaded = false
141 private(set) var readmeCacheMetadata: CacheEntryMetadata?
142
143 // MARK: - Artifacts state
144
145 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
146 private(set) var isLoadingArtifacts = false
147
148 // MARK: - Error
149
150 var error: String?
151
152 init(repository: RepositorySummary, client: SRHTClient) {
153 self.repository = repository
154 self.client = client
155 }
156
157 // MARK: - Commit log
158
159 private static let logQuery = """
160 query repoLog($rid: ID!, $cursor: Cursor) {
161 repository(rid: $rid) {
162 log(cursor: $cursor) {
163 results {
164 id
165 shortId
166 author { name email time }
167 message
168 }
169 cursor
170 }
171 }
172 }
173 """
174
175 func loadCommits() async {
176 guard !isLoadingCommits else { return }
177 isLoadingCommits = true
178 error = nil
179 commitCursor = nil
180 hasMoreCommits = true
181
182 do {
183 let page = try await fetchCommitPage(cursor: nil)
184 commits = page.results
185 commitCursor = page.cursor
186 hasMoreCommits = page.cursor != nil
187 } catch {
188 self.error = error.userFacingMessage
189 }
190
191 isLoadingCommits = false
192 }
193
194 func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
195 guard let last = commits.last,
196 last.id == currentItem.id,
197 hasMoreCommits,
198 !isLoadingMoreCommits else {
199 return
200 }
201
202 isLoadingMoreCommits = true
203
204 do {
205 let page = try await fetchCommitPage(cursor: commitCursor)
206 commits.append(contentsOf: page.results)
207 commitCursor = page.cursor
208 hasMoreCommits = page.cursor != nil
209 } catch {
210 self.error = error.userFacingMessage
211 }
212
213 isLoadingMoreCommits = false
214 }
215
216 private func fetchCommitPage(cursor: String?) async throws -> LogPage {
217 var variables: [String: any Sendable] = ["rid": repository.rid]
218 if let cursor {
219 variables["cursor"] = cursor
220 }
221 let result: LogResponse
222 do {
223 result = try await client.execute(
224 service: service,
225 query: Self.logQuery,
226 variables: variables,
227 responseType: LogResponse.self
228 )
229 } catch {
230 if isEmptyRepositoryError(error) {
231 return LogPage(results: [], cursor: nil)
232 }
233 throw error
234 }
235 guard let repo = result.repository else {
236 return LogPage(results: [], cursor: nil)
237 }
238 return repo.log
239 }
240
241 // MARK: - References
242
243 private static let refsQuery = """
244 query refs($rid: ID!, $cursor: Cursor) {
245 repository(rid: $rid) {
246 references(cursor: $cursor) {
247 results {
248 name
249 target
250 follow {
251 ... on Commit { author { time } }
252 ... on Tag { tagger { time } }
253 }
254 }
255 cursor
256 }
257 }
258 }
259 """
260
261 func loadReferences() async {
262 guard !isLoadingRefs else { return }
263 isLoadingRefs = true
264 error = nil
265
266 do {
267 let allRefs = try await fetchAllReferences()
268 branches = allRefs
269 .filter { $0.name.hasPrefix("refs/heads/") }
270 .map { $0.toDetail() }
271 .sorted(by: Self.sortBranches)
272 tags = allRefs
273 .filter { $0.name.hasPrefix("refs/tags/") }
274 .map { $0.toDetail() }
275 .sorted(by: Self.sortTags)
276 } catch {
277 if isEmptyRepositoryError(error) {
278 branches = []
279 tags = []
280 } else {
281 self.error = error.userFacingMessage
282 }
283 }
284
285 isLoadingRefs = false
286 }
287
288 var defaultBranchReference: ReferenceDetail? {
289 if let headName = repository.head?.name,
290 let branch = branches.first(where: { $0.name == headName }) {
291 return branch
292 }
293 return branches.first
294 }
295
296 var latestTagReference: ReferenceDetail? {
297 tags.first
298 }
299
300 private func fetchAllReferences() async throws -> [ReferencePayload] {
301 var allRefs: [ReferencePayload] = []
302 var cursor: String?
303
304 repeat {
305 let page = try await fetchReferencePage(cursor: cursor)
306 allRefs.append(contentsOf: page.results)
307 cursor = page.cursor
308 } while cursor != nil
309
310 return allRefs
311 }
312
313 private func fetchReferencePage(cursor: String?) async throws -> RefsPage {
314 var variables: [String: any Sendable] = ["rid": repository.rid]
315 if let cursor {
316 variables["cursor"] = cursor
317 }
318
319 let result = try await client.execute(
320 service: service,
321 query: Self.refsQuery,
322 variables: variables,
323 responseType: RefsResponse.self
324 )
325
326 return result.repository?.references ?? RefsPage(results: [], cursor: nil)
327 }
328
329 nonisolated private static func sortBranches(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
330 lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
331 }
332
333 nonisolated private static func sortTags(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
334 switch (lhs.date, rhs.date) {
335 case let (lhsDate?, rhsDate?) where lhsDate != rhsDate:
336 return lhsDate > rhsDate
337 case (.some, nil):
338 return true
339 case (nil, .some):
340 return false
341 default:
342 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
343 }
344 }
345
346 // MARK: - README
347
348 private static let readmeQuery = """
349 query readme($rid: ID!) {
350 repository(rid: $rid) {
351 readme
352 }
353 }
354 """
355
356 private static func readmeFileQuery(filename: String) -> String {
357 """
358 query readmeFile($rid: ID!) {
359 repository(rid: $rid) {
360 readme: path(revspec: "HEAD", path: "\(filename)") {
361 object {
362 ... on TextBlob { text }
363 }
364 }
365 }
366 }
367 """
368 }
369
370 private static let readmeFilenames = [
371 "README.md", "README.org", "README.txt", "README",
372 "readme.md", "readme.org"
373 ]
374
375 func loadReadme() async {
376 guard !isLoadingReadme, !readmeLoaded else { return }
377 isLoadingReadme = true
378 defer { isLoadingReadme = false }
379 error = nil
380
381 do {
382 // Step 1: Check the custom HTML readme set via the web UI
383 let result = try await client.executeCached(
384 service: service,
385 query: Self.readmeQuery,
386 variables: ["rid": repository.rid],
387 responseType: ReadmeResponse.self,
388 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid),
389 resourceType: .repositoryReadme,
390 ttl: APICacheTTLs.movingRefFileContent,
391 policy: .cacheFirstThenRefresh
392 )
393 readmeCacheMetadata = result.metadata
394 if let html = result.value.repository?.readme, !html.isEmpty {
395 readmePath = nil
396 readmeContent = .html(html)
397 readmeLoaded = true
398 return
399 }
400
401 // Step 2: Try each README filename sequentially
402 for filename in Self.readmeFilenames {
403 let pathResult: PathResponse
404 do {
405 let cached = try await client.executeCached(
406 service: service,
407 query: Self.readmeFileQuery(filename: filename),
408 variables: ["rid": repository.rid],
409 responseType: PathResponse.self,
410 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid, path: filename),
411 resourceType: .repositoryReadme,
412 ttl: APICacheTTLs.movingRefFileContent,
413 policy: .cacheFirstThenRefresh
414 )
415 pathResult = cached.value
416 readmeCacheMetadata = cached.metadata
417 } catch {
418 if isEmptyRepositoryError(error) {
419 readmeContent = nil
420 readmePath = nil
421 readmeLoaded = true
422 return
423 }
424 throw error
425 }
426 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
427 readmePath = filename
428 if filename.hasSuffix(".md") {
429 readmeContent = .markdown(text)
430 } else if filename.hasSuffix(".org") {
431 readmeContent = .org(text)
432 } else {
433 readmeContent = .plainText(text)
434 }
435 readmeLoaded = true
436 return
437 }
438 }
439
440 // Step 3: No readme found
441 readmeContent = nil
442 readmePath = nil
443 readmeLoaded = true
444 } catch {
445 self.error = error.userFacingMessage
446 }
447 }
448
449 private func isEmptyRepositoryError(_ error: Error) -> Bool {
450 error.matchesGraphQLErrorClassification(.missingReference)
451 || error.matchesGraphQLErrorClassification(.unknownRevision)
452 || error.matchesGraphQLErrorClassification(.noRows)
453 || error.matchesGraphQLErrorClassification(.notFound)
454 || error.containsGraphQLErrorMessage("missing")
455 || error.containsGraphQLErrorMessage("internal system error")
456 }
457
458 // MARK: - Artifacts
459
460 private static let artifactsQuery = """
461 query artifacts($rid: ID!) {
462 repository(rid: $rid) {
463 references {
464 results {
465 name
466 artifacts {
467 results {
468 id
469 filename
470 checksum
471 size
472 url
473 }
474 cursor
475 }
476 }
477 cursor
478 }
479 }
480 }
481 """
482
483 func loadArtifacts() async {
484 guard !isLoadingArtifacts else { return }
485 isLoadingArtifacts = true
486 error = nil
487
488 do {
489 let result = try await client.execute(
490 service: service,
491 query: Self.artifactsQuery,
492 variables: ["rid": repository.rid],
493 responseType: ArtifactsResponse.self
494 )
495 // Only include references that have at least one artifact.
496 referenceArtifacts = (result.repository?.references.results ?? [])
497 .filter { !$0.artifacts.results.isEmpty }
498 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
499 } catch {
500 if isEmptyRepositoryError(error) {
501 referenceArtifacts = []
502 } else {
503 self.error = error.userFacingMessage
504 }
505 }
506
507 isLoadingArtifacts = false
508 }
509}