krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.1: 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: [Reference]
28 let cursor: String?
29}
30
31private struct ReadmeResponse: Decodable, Sendable {
32 let repository: ReadmeRepository?
33}
34
35private struct ReadmeRepository: Decodable, Sendable {
36 let readme: String?
37}
38
39private struct PathResponse: Decodable, Sendable {
40 let repository: PathRepository?
41}
42
43private struct PathRepository: Decodable, Sendable {
44 let readme: PathEntry?
45}
46
47private struct PathEntry: Decodable, Sendable {
48 let object: PathObject?
49}
50
51private struct PathObject: Decodable, Sendable {
52 let text: String?
53}
54
55private struct ArtifactsResponse: Decodable, Sendable {
56 let repository: ArtifactsRepository?
57}
58
59private struct ArtifactsRepository: Decodable, Sendable {
60 let references: ArtifactRefsPage
61}
62
63private struct ArtifactRefsPage: Decodable, Sendable {
64 let results: [ArtifactRef]
65 let cursor: String?
66}
67
68private struct ArtifactRef: Decodable, Sendable {
69 let name: String
70 let artifacts: ArtifactPage
71}
72
73// MARK: - View Model
74
75@Observable
76@MainActor
77final class RepositoryDetailViewModel {
78
79 enum Tab: String, CaseIterable {
80 case summary = "Summary"
81 case tree = "Tree"
82 case log = "Log"
83 case refs = "Refs"
84 case artifacts = "Artifacts"
85 }
86
87 let repository: RepositorySummary
88 private var service: SRHTService { repository.service }
89 private let client: SRHTClient
90
91 // MARK: - Commit log state
92
93 private(set) var commits: [CommitSummary] = []
94 private(set) var isLoadingCommits = false
95 private(set) var isLoadingMoreCommits = false
96 private var commitCursor: String?
97 private var hasMoreCommits = true
98
99 // MARK: - References state
100
101 private(set) var branches: [Reference] = []
102 private(set) var tags: [Reference] = []
103 private(set) var isLoadingRefs = false
104
105 // MARK: - README state
106
107 enum ReadmeContent {
108 case html(String)
109 case markdown(String)
110 case org(String)
111 case plainText(String)
112 }
113
114 private(set) var readmeContent: ReadmeContent?
115 private(set) var readmePath: String?
116 private(set) var isLoadingReadme = false
117 private(set) var readmeLoaded = false
118
119 // MARK: - Artifacts state
120
121 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
122 private(set) var isLoadingArtifacts = false
123
124 // MARK: - Error
125
126 var error: String?
127
128 init(repository: RepositorySummary, client: SRHTClient) {
129 self.repository = repository
130 self.client = client
131 }
132
133 // MARK: - Commit log
134
135 private static let logQuery = """
136 query repoLog($rid: ID!, $cursor: Cursor) {
137 repository(rid: $rid) {
138 log(cursor: $cursor) {
139 results {
140 id
141 shortId
142 author { name email time }
143 message
144 }
145 cursor
146 }
147 }
148 }
149 """
150
151 func loadCommits() async {
152 guard !isLoadingCommits else { return }
153 isLoadingCommits = true
154 error = nil
155 commitCursor = nil
156 hasMoreCommits = true
157
158 do {
159 let page = try await fetchCommitPage(cursor: nil)
160 commits = page.results
161 commitCursor = page.cursor
162 hasMoreCommits = page.cursor != nil
163 } catch {
164 self.error = error.userFacingMessage
165 }
166
167 isLoadingCommits = false
168 }
169
170 func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
171 guard let last = commits.last,
172 last.id == currentItem.id,
173 hasMoreCommits,
174 !isLoadingMoreCommits else {
175 return
176 }
177
178 isLoadingMoreCommits = true
179
180 do {
181 let page = try await fetchCommitPage(cursor: commitCursor)
182 commits.append(contentsOf: page.results)
183 commitCursor = page.cursor
184 hasMoreCommits = page.cursor != nil
185 } catch {
186 self.error = error.userFacingMessage
187 }
188
189 isLoadingMoreCommits = false
190 }
191
192 private func fetchCommitPage(cursor: String?) async throws -> LogPage {
193 var variables: [String: any Sendable] = ["rid": repository.rid]
194 if let cursor {
195 variables["cursor"] = cursor
196 }
197 let result: LogResponse
198 do {
199 result = try await client.execute(
200 service: service,
201 query: Self.logQuery,
202 variables: variables,
203 responseType: LogResponse.self
204 )
205 } catch {
206 if isMissingGitReferenceError(error) {
207 return LogPage(results: [], cursor: nil)
208 }
209 throw error
210 }
211 guard let repo = result.repository else {
212 return LogPage(results: [], cursor: nil)
213 }
214 return repo.log
215 }
216
217 // MARK: - References
218
219 private static let refsQuery = """
220 query refs($rid: ID!) {
221 repository(rid: $rid) {
222 references {
223 results { name target }
224 cursor
225 }
226 }
227 }
228 """
229
230 func loadReferences() async {
231 guard !isLoadingRefs else { return }
232 isLoadingRefs = true
233 error = nil
234
235 do {
236 let result = try await client.execute(
237 service: service,
238 query: Self.refsQuery,
239 variables: ["rid": repository.rid],
240 responseType: RefsResponse.self
241 )
242 let allRefs = result.repository?.references.results ?? []
243 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
244 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
245 } catch {
246 self.error = error.userFacingMessage
247 }
248
249 isLoadingRefs = false
250 }
251
252 // MARK: - README
253
254 private static let readmeQuery = """
255 query readme($rid: ID!) {
256 repository(rid: $rid) {
257 readme
258 }
259 }
260 """
261
262 private static func readmeFileQuery(filename: String) -> String {
263 """
264 query readmeFile($rid: ID!) {
265 repository(rid: $rid) {
266 readme: path(revspec: "HEAD", path: "\(filename)") {
267 object {
268 ... on TextBlob { text }
269 }
270 }
271 }
272 }
273 """
274 }
275
276 private static let readmeFilenames = [
277 "README.md", "README.org", "README.txt", "README",
278 "readme.md", "readme.org"
279 ]
280
281 func loadReadme() async {
282 guard !isLoadingReadme, !readmeLoaded else { return }
283 isLoadingReadme = true
284 defer { isLoadingReadme = false }
285 error = nil
286
287 do {
288 // Step 1: Check the custom HTML readme set via the web UI
289 let result = try await client.execute(
290 service: service,
291 query: Self.readmeQuery,
292 variables: ["rid": repository.rid],
293 responseType: ReadmeResponse.self
294 )
295 if let html = result.repository?.readme, !html.isEmpty {
296 readmePath = nil
297 readmeContent = .html(html)
298 readmeLoaded = true
299 return
300 }
301
302 // Step 2: Try each README filename sequentially
303 for filename in Self.readmeFilenames {
304 let pathResult: PathResponse
305 do {
306 pathResult = try await client.execute(
307 service: service,
308 query: Self.readmeFileQuery(filename: filename),
309 variables: ["rid": repository.rid],
310 responseType: PathResponse.self
311 )
312 } catch {
313 if isMissingGitReferenceError(error) {
314 readmeContent = nil
315 readmePath = nil
316 readmeLoaded = true
317 return
318 }
319 throw error
320 }
321 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
322 readmePath = filename
323 if filename.hasSuffix(".md") {
324 readmeContent = .markdown(text)
325 } else if filename.hasSuffix(".org") {
326 readmeContent = .org(text)
327 } else {
328 readmeContent = .plainText(text)
329 }
330 readmeLoaded = true
331 return
332 }
333 }
334
335 // Step 3: No readme found
336 readmeContent = nil
337 readmePath = nil
338 readmeLoaded = true
339 } catch {
340 self.error = error.userFacingMessage
341 }
342 }
343
344 private func isMissingGitReferenceError(_ error: Error) -> Bool {
345 guard let srhtError = error as? SRHTError else { return false }
346 guard case .graphQLErrors(let errors) = srhtError else { return false }
347 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
348 }
349
350 // MARK: - Artifacts
351
352 private static let artifactsQuery = """
353 query artifacts($rid: ID!) {
354 repository(rid: $rid) {
355 references {
356 results {
357 name
358 artifacts {
359 results {
360 id
361 filename
362 checksum
363 size
364 url
365 }
366 cursor
367 }
368 }
369 cursor
370 }
371 }
372 }
373 """
374
375 func loadArtifacts() async {
376 guard !isLoadingArtifacts else { return }
377 isLoadingArtifacts = true
378 error = nil
379
380 do {
381 let result = try await client.execute(
382 service: service,
383 query: Self.artifactsQuery,
384 variables: ["rid": repository.rid],
385 responseType: ArtifactsResponse.self
386 )
387 // Only include references that have at least one artifact.
388 referenceArtifacts = (result.repository?.references.results ?? [])
389 .filter { !$0.artifacts.results.isEmpty }
390 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
391 } catch {
392 self.error = error.userFacingMessage
393 }
394
395 isLoadingArtifacts = false
396 }
397}