krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.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 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}