krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.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: [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 UploadArtifactResponse: Decodable, Sendable {
79 let uploadArtifact: ArtifactInfo
80}
81
82private struct DeleteArtifactResponse: Decodable, Sendable {
83 /// Nullable in the schema: sr.ht returns null when there was no artifact to
84 /// remove, which is still a success from the caller's point of view.
85 let deleteArtifact: ArtifactIDPayload?
86}
87
88private struct ArtifactIDPayload: Decodable, Sendable {
89 let id: Int
90}
91
92private struct ArtifactsResponse: Decodable, Sendable {
93 let repository: ArtifactsRepository?
94}
95
96private struct ArtifactsRepository: Decodable, Sendable {
97 let references: ArtifactRefsPage
98}
99
100private struct ArtifactRefsPage: Decodable, Sendable {
101 let results: [ArtifactRef]
102 let cursor: String?
103}
104
105private struct ArtifactRef: Decodable, Sendable {
106 let name: String
107 let artifacts: ArtifactPage
108}
109
110// MARK: - View Model
111
112@Observable
113@MainActor
114final class RepositoryDetailViewModel {
115
116 enum Tab: String, CaseIterable {
117 case summary = "Summary"
118 case tree = "Tree"
119 case log = "Log"
120 case refs = "Refs"
121 case artifacts = "Artifacts"
122 }
123
124 let repository: RepositorySummary
125 private var service: SRHTService { repository.service }
126 private let client: SRHTClient
127
128 // MARK: - Commit log state
129
130 private(set) var commits: [CommitSummary] = []
131 private(set) var isLoadingCommits = false
132 private(set) var isLoadingMoreCommits = false
133 private var commitCursor: String?
134 private var hasMoreCommits = true
135
136 // MARK: - References state
137
138 private(set) var branches: [ReferenceDetail] = []
139 private(set) var tags: [ReferenceDetail] = []
140 private(set) var isLoadingRefs = false
141
142 // MARK: - README state
143
144 enum ReadmeContent {
145 case html(String)
146 case markdown(String)
147 case org(String)
148 case plainText(String)
149 }
150
151 private(set) var readmeContent: ReadmeContent?
152 private(set) var readmePath: String?
153 private(set) var isLoadingReadme = false
154 private(set) var readmeLoaded = false
155 private(set) var readmeCacheMetadata: CacheEntryMetadata?
156
157 // MARK: - Artifacts state
158
159 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
160 private(set) var isLoadingArtifacts = false
161 private(set) var isMutatingArtifact = false
162
163 // MARK: - Error
164
165 var error: String?
166
167 init(repository: RepositorySummary, client: SRHTClient) {
168 self.repository = repository
169 self.client = client
170 }
171
172 // MARK: - Commit log
173
174 private static let logQuery = """
175 query repoLog($rid: ID!, $cursor: Cursor) {
176 repository(rid: $rid) {
177 log(cursor: $cursor) {
178 results {
179 id
180 shortId
181 author { name email time }
182 message
183 }
184 cursor
185 }
186 }
187 }
188 """
189
190 func loadCommits() async {
191 guard !isLoadingCommits else { return }
192 isLoadingCommits = true
193 error = nil
194 commitCursor = nil
195 hasMoreCommits = true
196
197 do {
198 let page = try await fetchCommitPage(cursor: nil)
199 commits = page.results
200 commitCursor = page.cursor
201 hasMoreCommits = page.cursor != nil
202 } catch {
203 self.error = error.userFacingMessage
204 }
205
206 isLoadingCommits = false
207 }
208
209 func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
210 guard let last = commits.last,
211 last.id == currentItem.id,
212 hasMoreCommits,
213 !isLoadingMoreCommits else {
214 return
215 }
216
217 isLoadingMoreCommits = true
218
219 do {
220 let page = try await fetchCommitPage(cursor: commitCursor)
221 commits.append(contentsOf: page.results)
222 commitCursor = page.cursor
223 hasMoreCommits = page.cursor != nil
224 } catch {
225 self.error = error.userFacingMessage
226 }
227
228 isLoadingMoreCommits = false
229 }
230
231 private func fetchCommitPage(cursor: String?) async throws -> LogPage {
232 var variables: [String: any Sendable] = ["rid": repository.rid]
233 if let cursor {
234 variables["cursor"] = cursor
235 }
236 let result: LogResponse
237 do {
238 result = try await client.execute(
239 service: service,
240 query: Self.logQuery,
241 variables: variables,
242 responseType: LogResponse.self
243 )
244 } catch {
245 if isEmptyRepositoryError(error) {
246 return LogPage(results: [], cursor: nil)
247 }
248 throw error
249 }
250 guard let repo = result.repository else {
251 return LogPage(results: [], cursor: nil)
252 }
253 return repo.log
254 }
255
256 // MARK: - References
257
258 private static let refsQuery = """
259 query refs($rid: ID!, $cursor: Cursor) {
260 repository(rid: $rid) {
261 references(cursor: $cursor) {
262 results {
263 name
264 target
265 follow {
266 ... on Commit { author { time } }
267 ... on Tag { tagger { time } }
268 }
269 }
270 cursor
271 }
272 }
273 }
274 """
275
276 func loadReferences() async {
277 guard !isLoadingRefs else { return }
278 isLoadingRefs = true
279 error = nil
280
281 do {
282 let allRefs = try await fetchAllReferences()
283 branches = allRefs
284 .filter { $0.name.hasPrefix("refs/heads/") }
285 .map { $0.toDetail() }
286 .sorted(by: Self.sortBranches)
287 tags = allRefs
288 .filter { $0.name.hasPrefix("refs/tags/") }
289 .map { $0.toDetail() }
290 .sorted(by: Self.sortTags)
291 } catch {
292 if isEmptyRepositoryError(error) {
293 branches = []
294 tags = []
295 } else {
296 self.error = error.userFacingMessage
297 }
298 }
299
300 isLoadingRefs = false
301 }
302
303 var defaultBranchReference: ReferenceDetail? {
304 if let headName = repository.head?.name,
305 let branch = branches.first(where: { $0.name == headName }) {
306 return branch
307 }
308 return branches.first
309 }
310
311 var latestTagReference: ReferenceDetail? {
312 tags.first
313 }
314
315 private func fetchAllReferences() async throws -> [ReferencePayload] {
316 var allRefs: [ReferencePayload] = []
317 var cursor: String?
318
319 repeat {
320 let page = try await fetchReferencePage(cursor: cursor)
321 allRefs.append(contentsOf: page.results)
322 cursor = page.cursor
323 } while cursor != nil
324
325 return allRefs
326 }
327
328 private func fetchReferencePage(cursor: String?) async throws -> RefsPage {
329 var variables: [String: any Sendable] = ["rid": repository.rid]
330 if let cursor {
331 variables["cursor"] = cursor
332 }
333
334 let result = try await client.execute(
335 service: service,
336 query: Self.refsQuery,
337 variables: variables,
338 responseType: RefsResponse.self
339 )
340
341 return result.repository?.references ?? RefsPage(results: [], cursor: nil)
342 }
343
344 nonisolated private static func sortBranches(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
345 lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
346 }
347
348 nonisolated private static func sortTags(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
349 switch (lhs.date, rhs.date) {
350 case let (lhsDate?, rhsDate?) where lhsDate != rhsDate:
351 return lhsDate > rhsDate
352 case (.some, nil):
353 return true
354 case (nil, .some):
355 return false
356 default:
357 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
358 }
359 }
360
361 // MARK: - README
362
363 private static let readmeQuery = """
364 query readme($rid: ID!) {
365 repository(rid: $rid) {
366 readme
367 }
368 }
369 """
370
371 private static func readmeFileQuery(filename: String) -> String {
372 """
373 query readmeFile($rid: ID!) {
374 repository(rid: $rid) {
375 readme: path(revspec: "HEAD", path: "\(filename)") {
376 object {
377 ... on TextBlob { text }
378 }
379 }
380 }
381 }
382 """
383 }
384
385 private static let readmeFilenames = [
386 "README.md", "README.org", "README.txt", "README",
387 "readme.md", "readme.org"
388 ]
389
390 func loadReadme() async {
391 guard !isLoadingReadme, !readmeLoaded else { return }
392 isLoadingReadme = true
393 defer { isLoadingReadme = false }
394 error = nil
395
396 do {
397 // Step 1: Check the custom HTML readme set via the web UI
398 let result = try await client.executeCached(
399 service: service,
400 query: Self.readmeQuery,
401 variables: ["rid": repository.rid],
402 responseType: ReadmeResponse.self,
403 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid),
404 resourceType: .repositoryReadme,
405 ttl: APICacheTTLs.movingRefFileContent,
406 policy: .cacheFirstThenRefresh
407 )
408 readmeCacheMetadata = result.metadata
409 if let html = result.value.repository?.readme, !html.isEmpty {
410 readmePath = nil
411 readmeContent = .html(html)
412 readmeLoaded = true
413 return
414 }
415
416 // Step 2: Try each README filename sequentially
417 for filename in Self.readmeFilenames {
418 let pathResult: PathResponse
419 do {
420 let cached = try await client.executeCached(
421 service: service,
422 query: Self.readmeFileQuery(filename: filename),
423 variables: ["rid": repository.rid],
424 responseType: PathResponse.self,
425 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid, path: filename),
426 resourceType: .repositoryReadme,
427 ttl: APICacheTTLs.movingRefFileContent,
428 policy: .cacheFirstThenRefresh
429 )
430 pathResult = cached.value
431 readmeCacheMetadata = cached.metadata
432 } catch {
433 if isEmptyRepositoryError(error) {
434 readmeContent = nil
435 readmePath = nil
436 readmeLoaded = true
437 return
438 }
439 throw error
440 }
441 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
442 readmePath = filename
443 if filename.hasSuffix(".md") {
444 readmeContent = .markdown(text)
445 } else if filename.hasSuffix(".org") {
446 readmeContent = .org(text)
447 } else {
448 readmeContent = .plainText(text)
449 }
450 readmeLoaded = true
451 return
452 }
453 }
454
455 // Step 3: No readme found
456 readmeContent = nil
457 readmePath = nil
458 readmeLoaded = true
459 } catch {
460 self.error = error.userFacingMessage
461 }
462 }
463
464 private func isEmptyRepositoryError(_ error: Error) -> Bool {
465 error.matchesGraphQLErrorClassification(.missingReference)
466 || error.matchesGraphQLErrorClassification(.unknownRevision)
467 || error.matchesGraphQLErrorClassification(.noRows)
468 || error.matchesGraphQLErrorClassification(.notFound)
469 || error.containsGraphQLErrorMessage("missing")
470 || error.containsGraphQLErrorMessage("internal system error")
471 }
472
473 // MARK: - Artifacts
474
475 /// `file` is a top-level Upload variable here, unlike meta's avatar upload
476 /// where it is nested inside an input object.
477 private static let uploadArtifactMutation = """
478 mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) {
479 uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) {
480 id
481 filename
482 checksum
483 size
484 url
485 }
486 }
487 """
488
489 private static let deleteArtifactMutation = """
490 mutation deleteArtifact($id: Int!) {
491 deleteArtifact(id: $id) { id }
492 }
493 """
494
495 private static let artifactsQuery = """
496 query artifacts($rid: ID!) {
497 repository(rid: $rid) {
498 references {
499 results {
500 name
501 artifacts {
502 results {
503 id
504 filename
505 checksum
506 size
507 url
508 }
509 cursor
510 }
511 }
512 cursor
513 }
514 }
515 }
516 """
517
518 /// Attaches a file to the tag named by `revspec`.
519 ///
520 /// sr.ht requires the filename to be unique among the repository's artifacts,
521 /// and rejects a duplicate rather than replacing it, so the error is surfaced
522 /// as-is rather than being retried.
523 @discardableResult
524 func uploadArtifact(revspec: String, fileURL: URL) async -> Bool {
525 guard !isMutatingArtifact else { return false }
526 isMutatingArtifact = true
527 error = nil
528 defer { isMutatingArtifact = false }
529
530 let needsScopedAccess = fileURL.startAccessingSecurityScopedResource()
531 defer {
532 if needsScopedAccess {
533 fileURL.stopAccessingSecurityScopedResource()
534 }
535 }
536
537 let fileData: Data
538 do {
539 fileData = try Data(contentsOf: fileURL)
540 } catch {
541 self.error = "Couldn't read \(fileURL.lastPathComponent)."
542 return false
543 }
544
545 // sr.ht streams the upload into S3, which rejects a zero-part multipart
546 // completion with "MalformedXML" — an error that says nothing about the
547 // actual problem. Catch it here where we can name it.
548 guard !fileData.isEmpty else {
549 self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts."
550 return false
551 }
552
553 do {
554 _ = try await client.executeMultipart(
555 service: service,
556 query: Self.uploadArtifactMutation,
557 variables: [
558 "repoId": repository.id,
559 "revspec": revspec,
560 "file": nil as String? as Any
561 ],
562 file: MultipartUploadFile(
563 variablePath: "file",
564 fileData: fileData,
565 fileName: fileURL.lastPathComponent,
566 mimeType: Self.mimeType(for: fileURL)
567 ),
568 responseType: UploadArtifactResponse.self
569 )
570 await reloadArtifacts()
571 return true
572 } catch {
573 self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)"
574 return false
575 }
576 }
577
578 /// Downloads an artifact and returns a local file URL to share.
579 ///
580 /// `Artifact.url` points at the API origin, not the web one, and returns an
581 /// auth error to anything without a bearer token — so it cannot be opened in
582 /// a browser. Fetch it here and hand the user the file instead.
583 func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? {
584 guard !isMutatingArtifact else { return nil }
585 isMutatingArtifact = true
586 error = nil
587 defer { isMutatingArtifact = false }
588
589 do {
590 let data = try await client.fetchData(url: artifact.url)
591 let destination = FileManager.default.temporaryDirectory
592 .appendingPathComponent(artifact.filename)
593 try data.write(to: destination, options: .atomic)
594 return destination
595 } catch {
596 self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)"
597 return nil
598 }
599 }
600
601 @discardableResult
602 func deleteArtifact(id: Int) async -> Bool {
603 guard !isMutatingArtifact else { return false }
604 isMutatingArtifact = true
605 error = nil
606 defer { isMutatingArtifact = false }
607
608 do {
609 _ = try await client.execute(
610 service: service,
611 query: Self.deleteArtifactMutation,
612 variables: ["id": id],
613 responseType: DeleteArtifactResponse.self
614 )
615 await reloadArtifacts()
616 return true
617 } catch {
618 self.error = "Couldn't delete the artifact. \(error.userFacingMessage)"
619 return false
620 }
621 }
622
623 private func reloadArtifacts() async {
624 isLoadingArtifacts = false
625 await loadArtifacts()
626 }
627
628 /// Artifacts are release tarballs and signatures rather than media, so a
629 /// generic binary type is honest more often than guessing from the extension.
630 private nonisolated static func mimeType(for _: URL) -> String {
631 "application/octet-stream"
632 }
633
634 func loadArtifacts() async {
635 guard !isLoadingArtifacts else { return }
636 isLoadingArtifacts = true
637 error = nil
638
639 do {
640 let result = try await client.execute(
641 service: service,
642 query: Self.artifactsQuery,
643 variables: ["rid": repository.rid],
644 responseType: ArtifactsResponse.self
645 )
646 // Only include references that have at least one artifact.
647 referenceArtifacts = (result.repository?.references.results ?? [])
648 .filter { !$0.artifacts.results.isEmpty }
649 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
650 } catch {
651 if isEmptyRepositoryError(error) {
652 referenceArtifacts = []
653 } else {
654 self.error = error.userFacingMessage
655 }
656 }
657
658 isLoadingArtifacts = false
659 }
660}