krz/hutch

an ios client for sourcehut

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

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