krz/hutch

an ios client for sourcehut

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

v3.2.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
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 isEmptyRepositoryError(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!, $cursor: Cursor) {
244        repository(rid: $rid) {
245            references(cursor: $cursor) {
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 allRefs = try await fetchAllReferences()
267            branches = allRefs
268                .filter { $0.name.hasPrefix("refs/heads/") }
269                .map { $0.toDetail() }
270                .sorted(by: Self.sortBranches)
271            tags = allRefs
272                .filter { $0.name.hasPrefix("refs/tags/") }
273                .map { $0.toDetail() }
274                .sorted(by: Self.sortTags)
275        } catch {
276            if isEmptyRepositoryError(error) {
277                branches = []
278                tags = []
279            } else {
280                self.error = error.userFacingMessage
281            }
282        }
283
284        isLoadingRefs = false
285    }
286
287    var defaultBranchReference: ReferenceDetail? {
288        if let headName = repository.head?.name,
289           let branch = branches.first(where: { $0.name == headName }) {
290            return branch
291        }
292        return branches.first
293    }
294
295    var latestTagReference: ReferenceDetail? {
296        tags.first
297    }
298
299    private func fetchAllReferences() async throws -> [ReferencePayload] {
300        var allRefs: [ReferencePayload] = []
301        var cursor: String?
302
303        repeat {
304            let page = try await fetchReferencePage(cursor: cursor)
305            allRefs.append(contentsOf: page.results)
306            cursor = page.cursor
307        } while cursor != nil
308
309        return allRefs
310    }
311
312    private func fetchReferencePage(cursor: String?) async throws -> RefsPage {
313        var variables: [String: any Sendable] = ["rid": repository.rid]
314        if let cursor {
315            variables["cursor"] = cursor
316        }
317
318        let result = try await client.execute(
319            service: service,
320            query: Self.refsQuery,
321            variables: variables,
322            responseType: RefsResponse.self
323        )
324
325        return result.repository?.references ?? RefsPage(results: [], cursor: nil)
326    }
327
328    nonisolated private static func sortBranches(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
329        lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
330    }
331
332    nonisolated private static func sortTags(_ lhs: ReferenceDetail, _ rhs: ReferenceDetail) -> Bool {
333        switch (lhs.date, rhs.date) {
334        case let (lhsDate?, rhsDate?) where lhsDate != rhsDate:
335            return lhsDate > rhsDate
336        case (.some, nil):
337            return true
338        case (nil, .some):
339            return false
340        default:
341            return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
342        }
343    }
344
345    // MARK: - README
346
347    private static let readmeQuery = """
348    query readme($rid: ID!) {
349        repository(rid: $rid) {
350            readme
351        }
352    }
353    """
354
355    private static func readmeFileQuery(filename: String) -> String {
356        """
357        query readmeFile($rid: ID!) {
358            repository(rid: $rid) {
359                readme: path(revspec: "HEAD", path: "\(filename)") {
360                    object {
361                        ... on TextBlob { text }
362                    }
363                }
364            }
365        }
366        """
367    }
368
369    private static let readmeFilenames = [
370        "README.md", "README.org", "README.txt", "README",
371        "readme.md", "readme.org"
372    ]
373
374    func loadReadme() async {
375        guard !isLoadingReadme, !readmeLoaded else { return }
376        isLoadingReadme = true
377        defer { isLoadingReadme = false }
378        error = nil
379
380        do {
381            // Step 1: Check the custom HTML readme set via the web UI
382            let result = try await client.execute(
383                service: service,
384                query: Self.readmeQuery,
385                variables: ["rid": repository.rid],
386                responseType: ReadmeResponse.self
387            )
388            if let html = result.repository?.readme, !html.isEmpty {
389                readmePath = nil
390                readmeContent = .html(html)
391                readmeLoaded = true
392                return
393            }
394
395            // Step 2: Try each README filename sequentially
396            for filename in Self.readmeFilenames {
397                let pathResult: PathResponse
398                do {
399                    pathResult = try await client.execute(
400                        service: service,
401                        query: Self.readmeFileQuery(filename: filename),
402                        variables: ["rid": repository.rid],
403                        responseType: PathResponse.self
404                    )
405                } catch {
406                    if isEmptyRepositoryError(error) {
407                        readmeContent = nil
408                        readmePath = nil
409                        readmeLoaded = true
410                        return
411                    }
412                    throw error
413                }
414                if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
415                    readmePath = filename
416                    if filename.hasSuffix(".md") {
417                        readmeContent = .markdown(text)
418                    } else if filename.hasSuffix(".org") {
419                        readmeContent = .org(text)
420                    } else {
421                        readmeContent = .plainText(text)
422                    }
423                    readmeLoaded = true
424                    return
425                }
426            }
427
428            // Step 3: No readme found
429            readmeContent = nil
430            readmePath = nil
431            readmeLoaded = true
432        } catch {
433            self.error = error.userFacingMessage
434        }
435    }
436
437    private func isEmptyRepositoryError(_ error: Error) -> Bool {
438        error.matchesGraphQLErrorClassification(.missingReference)
439            || error.matchesGraphQLErrorClassification(.unknownRevision)
440            || error.matchesGraphQLErrorClassification(.noRows)
441            || error.matchesGraphQLErrorClassification(.notFound)
442            || error.containsGraphQLErrorMessage("missing")
443            || error.containsGraphQLErrorMessage("internal system error")
444    }
445
446    // MARK: - Artifacts
447
448    private static let artifactsQuery = """
449    query artifacts($rid: ID!) {
450        repository(rid: $rid) {
451            references {
452                results {
453                    name
454                    artifacts {
455                        results {
456                            id
457                            filename
458                            checksum
459                            size
460                            url
461                        }
462                        cursor
463                    }
464                }
465                cursor
466            }
467        }
468    }
469    """
470
471    func loadArtifacts() async {
472        guard !isLoadingArtifacts else { return }
473        isLoadingArtifacts = true
474        error = nil
475
476        do {
477            let result = try await client.execute(
478                service: service,
479                query: Self.artifactsQuery,
480                variables: ["rid": repository.rid],
481                responseType: ArtifactsResponse.self
482            )
483            // Only include references that have at least one artifact.
484            referenceArtifacts = (result.repository?.references.results ?? [])
485                .filter { !$0.artifacts.results.isEmpty }
486                .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
487        } catch {
488            if isEmptyRepositoryError(error) {
489                referenceArtifacts = []
490            } else {
491                self.error = error.userFacingMessage
492            }
493        }
494
495        isLoadingArtifacts = false
496    }
497}