krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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
142 // MARK: - Artifacts state
143
144 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
145 private(set) var isLoadingArtifacts = false
146
147 // MARK: - Error
148
149 var error: String?
150
151 init(repository: RepositorySummary, client: SRHTClient) {
152 self.repository = repository
153 self.client = client
154 }
155
156 // MARK: - Commit log
157
158 private static let logQuery = """
159 query repoLog($rid: ID!, $cursor: Cursor) {
160 repository(rid: $rid) {
161 log(cursor: $cursor) {
162 results {
163 id
164 shortId
165 author { name email time }
166 message
167 }
168 cursor
169 }
170 }
171 }
172 """
173
174 func loadCommits() async {
175 guard !isLoadingCommits else { return }
176 isLoadingCommits = true
177 error = nil
178 commitCursor = nil
179 hasMoreCommits = true
180
181 do {
182 let page = try await fetchCommitPage(cursor: nil)
183 commits = page.results
184 commitCursor = page.cursor
185 hasMoreCommits = page.cursor != nil
186 } catch {
187 self.error = error.userFacingMessage
188 }
189
190 isLoadingCommits = false
191 }
192
193 func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
194 guard let last = commits.last,
195 last.id == currentItem.id,
196 hasMoreCommits,
197 !isLoadingMoreCommits else {
198 return
199 }
200
201 isLoadingMoreCommits = true
202
203 do {
204 let page = try await fetchCommitPage(cursor: commitCursor)
205 commits.append(contentsOf: page.results)
206 commitCursor = page.cursor
207 hasMoreCommits = page.cursor != nil
208 } catch {
209 self.error = error.userFacingMessage
210 }
211
212 isLoadingMoreCommits = false
213 }
214
215 private func fetchCommitPage(cursor: String?) async throws -> LogPage {
216 var variables: [String: any Sendable] = ["rid": repository.rid]
217 if let cursor {
218 variables["cursor"] = cursor
219 }
220 let result: LogResponse
221 do {
222 result = try await client.execute(
223 service: service,
224 query: Self.logQuery,
225 variables: variables,
226 responseType: LogResponse.self
227 )
228 } catch {
229 if isEmptyRepositoryError(error) {
230 return LogPage(results: [], cursor: nil)
231 }
232 throw error
233 }
234 guard let repo = result.repository else {
235 return LogPage(results: [], cursor: nil)
236 }
237 return repo.log
238 }
239
240 // MARK: - References
241
242 private static let refsQuery = """
243 query refs($rid: ID!, $cursor: Cursor) {
244 repository(rid: $rid) {
245 references(cursor: $cursor) {
246 results {
247 name
248 target
249 follow {
250 ... on Commit { author { time } }
251 ... on Tag { tagger { time } }
252 }
253 }
254 cursor
255 }
256 }
257 }
258 """
259
260 func loadReferences() async {
261 guard !isLoadingRefs else { return }
262 isLoadingRefs = true
263 error = nil
264
265 do {
266 let allRefs = try await fetchAllReferences()
267 branches = allRefs
268 .filter { $0.name.hasPrefix("refs/heads/") }
269 .map { $0.toDetail() }
270 .sorted(by: Self.sortBranches)
271 tags = allRefs
272 .filter { $0.name.hasPrefix("refs/tags/") }
273 .map { $0.toDetail() }
274 .sorted(by: Self.sortTags)
275 } catch {
276 if isEmptyRepositoryError(error) {
277 branches = []
278 tags = []
279 } else {
280 self.error = error.userFacingMessage
281 }
282 }
283
284 isLoadingRefs = false
285 }
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
345 // MARK: - README
346
347 private static let readmeQuery = """
348 query readme($rid: ID!) {
349 repository(rid: $rid) {
350 readme
351 }
352 }
353 """
354
355 private static func readmeFileQuery(filename: String) -> String {
356 """
357 query readmeFile($rid: ID!) {
358 repository(rid: $rid) {
359 readme: path(revspec: "HEAD", path: "\(filename)") {
360 object {
361 ... on TextBlob { text }
362 }
363 }
364 }
365 }
366 """
367 }
368
369 private static let readmeFilenames = [
370 "README.md", "README.org", "README.txt", "README",
371 "readme.md", "readme.org"
372 ]
373
374 func loadReadme() async {
375 guard !isLoadingReadme, !readmeLoaded else { return }
376 isLoadingReadme = true
377 defer { isLoadingReadme = false }
378 error = nil
379
380 do {
381 // Step 1: Check the custom HTML readme set via the web UI
382 let result = try await client.execute(
383 service: service,
384 query: Self.readmeQuery,
385 variables: ["rid": repository.rid],
386 responseType: ReadmeResponse.self
387 )
388 if let html = result.repository?.readme, !html.isEmpty {
389 readmePath = nil
390 readmeContent = .html(html)
391 readmeLoaded = true
392 return
393 }
394
395 // Step 2: Try each README filename sequentially
396 for filename in Self.readmeFilenames {
397 let pathResult: PathResponse
398 do {
399 pathResult = try await client.execute(
400 service: service,
401 query: Self.readmeFileQuery(filename: filename),
402 variables: ["rid": repository.rid],
403 responseType: PathResponse.self
404 )
405 } catch {
406 if isEmptyRepositoryError(error) {
407 readmeContent = nil
408 readmePath = nil
409 readmeLoaded = true
410 return
411 }
412 throw error
413 }
414 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
415 readmePath = filename
416 if filename.hasSuffix(".md") {
417 readmeContent = .markdown(text)
418 } else if filename.hasSuffix(".org") {
419 readmeContent = .org(text)
420 } else {
421 readmeContent = .plainText(text)
422 }
423 readmeLoaded = true
424 return
425 }
426 }
427
428 // Step 3: No readme found
429 readmeContent = nil
430 readmePath = nil
431 readmeLoaded = true
432 } catch {
433 self.error = error.userFacingMessage
434 }
435 }
436
437 private func isEmptyRepositoryError(_ error: Error) -> Bool {
438 error.matchesGraphQLErrorClassification(.missingReference)
439 || error.matchesGraphQLErrorClassification(.unknownRevision)
440 || error.matchesGraphQLErrorClassification(.noRows)
441 || error.matchesGraphQLErrorClassification(.notFound)
442 || error.containsGraphQLErrorMessage("missing")
443 || error.containsGraphQLErrorMessage("internal system error")
444 }
445
446 // MARK: - Artifacts
447
448 private static let artifactsQuery = """
449 query artifacts($rid: ID!) {
450 repository(rid: $rid) {
451 references {
452 results {
453 name
454 artifacts {
455 results {
456 id
457 filename
458 checksum
459 size
460 url
461 }
462 cursor
463 }
464 }
465 cursor
466 }
467 }
468 }
469 """
470
471 func loadArtifacts() async {
472 guard !isLoadingArtifacts else { return }
473 isLoadingArtifacts = true
474 error = nil
475
476 do {
477 let result = try await client.execute(
478 service: service,
479 query: Self.artifactsQuery,
480 variables: ["rid": repository.rid],
481 responseType: ArtifactsResponse.self
482 )
483 // Only include references that have at least one artifact.
484 referenceArtifacts = (result.repository?.references.results ?? [])
485 .filter { !$0.artifacts.results.isEmpty }
486 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
487 } catch {
488 if isEmptyRepositoryError(error) {
489 referenceArtifacts = []
490 } else {
491 self.error = error.userFacingMessage
492 }
493 }
494
495 isLoadingArtifacts = false
496 }
497}