krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.10.3: 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 isMissingGitReferenceError(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!) {
244 repository(rid: $rid) {
245 references {
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 result = try await client.execute(
267 service: service,
268 query: Self.refsQuery,
269 variables: ["rid": repository.rid],
270 responseType: RefsResponse.self
271 )
272 let allRefs = result.repository?.references.results ?? []
273 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() }
274 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() }
275 } catch {
276 self.error = error.userFacingMessage
277 }
278
279 isLoadingRefs = false
280 }
281
282 // MARK: - README
283
284 private static let readmeQuery = """
285 query readme($rid: ID!) {
286 repository(rid: $rid) {
287 readme
288 }
289 }
290 """
291
292 private static func readmeFileQuery(filename: String) -> String {
293 """
294 query readmeFile($rid: ID!) {
295 repository(rid: $rid) {
296 readme: path(revspec: "HEAD", path: "\(filename)") {
297 object {
298 ... on TextBlob { text }
299 }
300 }
301 }
302 }
303 """
304 }
305
306 private static let readmeFilenames = [
307 "README.md", "README.org", "README.txt", "README",
308 "readme.md", "readme.org"
309 ]
310
311 func loadReadme() async {
312 guard !isLoadingReadme, !readmeLoaded else { return }
313 isLoadingReadme = true
314 defer { isLoadingReadme = false }
315 error = nil
316
317 do {
318 // Step 1: Check the custom HTML readme set via the web UI
319 let result = try await client.execute(
320 service: service,
321 query: Self.readmeQuery,
322 variables: ["rid": repository.rid],
323 responseType: ReadmeResponse.self
324 )
325 if let html = result.repository?.readme, !html.isEmpty {
326 readmePath = nil
327 readmeContent = .html(html)
328 readmeLoaded = true
329 return
330 }
331
332 // Step 2: Try each README filename sequentially
333 for filename in Self.readmeFilenames {
334 let pathResult: PathResponse
335 do {
336 pathResult = try await client.execute(
337 service: service,
338 query: Self.readmeFileQuery(filename: filename),
339 variables: ["rid": repository.rid],
340 responseType: PathResponse.self
341 )
342 } catch {
343 if isMissingGitReferenceError(error) {
344 readmeContent = nil
345 readmePath = nil
346 readmeLoaded = true
347 return
348 }
349 throw error
350 }
351 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
352 readmePath = filename
353 if filename.hasSuffix(".md") {
354 readmeContent = .markdown(text)
355 } else if filename.hasSuffix(".org") {
356 readmeContent = .org(text)
357 } else {
358 readmeContent = .plainText(text)
359 }
360 readmeLoaded = true
361 return
362 }
363 }
364
365 // Step 3: No readme found
366 readmeContent = nil
367 readmePath = nil
368 readmeLoaded = true
369 } catch {
370 self.error = error.userFacingMessage
371 }
372 }
373
374 private func isMissingGitReferenceError(_ error: Error) -> Bool {
375 guard let srhtError = error as? SRHTError else { return false }
376 guard case .graphQLErrors(let errors) = srhtError else { return false }
377 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
378 }
379
380 // MARK: - Artifacts
381
382 private static let artifactsQuery = """
383 query artifacts($rid: ID!) {
384 repository(rid: $rid) {
385 references {
386 results {
387 name
388 artifacts {
389 results {
390 id
391 filename
392 checksum
393 size
394 url
395 }
396 cursor
397 }
398 }
399 cursor
400 }
401 }
402 }
403 """
404
405 func loadArtifacts() async {
406 guard !isLoadingArtifacts else { return }
407 isLoadingArtifacts = true
408 error = nil
409
410 do {
411 let result = try await client.execute(
412 service: service,
413 query: Self.artifactsQuery,
414 variables: ["rid": repository.rid],
415 responseType: ArtifactsResponse.self
416 )
417 // Only include references that have at least one artifact.
418 referenceArtifacts = (result.repository?.references.results ?? [])
419 .filter { !$0.artifacts.results.isEmpty }
420 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
421 } catch {
422 self.error = error.userFacingMessage
423 }
424
425 isLoadingArtifacts = false
426 }
427}