krz/hutch

an ios client for sourcehut

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

v3.1.5: 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!) {
244        repository(rid: $rid) {
245            references {
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 result = try await client.execute(
267                service: service,
268                query: Self.refsQuery,
269                variables: ["rid": repository.rid],
270                responseType: RefsResponse.self
271            )
272            let allRefs = result.repository?.references.results ?? []
273            branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() }
274            tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() }
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    // MARK: - README
288
289    private static let readmeQuery = """
290    query readme($rid: ID!) {
291        repository(rid: $rid) {
292            readme
293        }
294    }
295    """
296
297    private static func readmeFileQuery(filename: String) -> String {
298        """
299        query readmeFile($rid: ID!) {
300            repository(rid: $rid) {
301                readme: path(revspec: "HEAD", path: "\(filename)") {
302                    object {
303                        ... on TextBlob { text }
304                    }
305                }
306            }
307        }
308        """
309    }
310
311    private static let readmeFilenames = [
312        "README.md", "README.org", "README.txt", "README",
313        "readme.md", "readme.org"
314    ]
315
316    func loadReadme() async {
317        guard !isLoadingReadme, !readmeLoaded else { return }
318        isLoadingReadme = true
319        defer { isLoadingReadme = false }
320        error = nil
321
322        do {
323            // Step 1: Check the custom HTML readme set via the web UI
324            let result = try await client.execute(
325                service: service,
326                query: Self.readmeQuery,
327                variables: ["rid": repository.rid],
328                responseType: ReadmeResponse.self
329            )
330            if let html = result.repository?.readme, !html.isEmpty {
331                readmePath = nil
332                readmeContent = .html(html)
333                readmeLoaded = true
334                return
335            }
336
337            // Step 2: Try each README filename sequentially
338            for filename in Self.readmeFilenames {
339                let pathResult: PathResponse
340                do {
341                    pathResult = try await client.execute(
342                        service: service,
343                        query: Self.readmeFileQuery(filename: filename),
344                        variables: ["rid": repository.rid],
345                        responseType: PathResponse.self
346                    )
347                } catch {
348                    if isEmptyRepositoryError(error) {
349                        readmeContent = nil
350                        readmePath = nil
351                        readmeLoaded = true
352                        return
353                    }
354                    throw error
355                }
356                if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
357                    readmePath = filename
358                    if filename.hasSuffix(".md") {
359                        readmeContent = .markdown(text)
360                    } else if filename.hasSuffix(".org") {
361                        readmeContent = .org(text)
362                    } else {
363                        readmeContent = .plainText(text)
364                    }
365                    readmeLoaded = true
366                    return
367                }
368            }
369
370            // Step 3: No readme found
371            readmeContent = nil
372            readmePath = nil
373            readmeLoaded = true
374        } catch {
375            self.error = error.userFacingMessage
376        }
377    }
378
379    private func isEmptyRepositoryError(_ error: Error) -> Bool {
380        error.matchesGraphQLErrorClassification(.missingReference)
381            || error.matchesGraphQLErrorClassification(.unknownRevision)
382            || error.matchesGraphQLErrorClassification(.noRows)
383            || error.matchesGraphQLErrorClassification(.notFound)
384            || error.containsGraphQLErrorMessage("missing")
385            || error.containsGraphQLErrorMessage("internal system error")
386    }
387
388    // MARK: - Artifacts
389
390    private static let artifactsQuery = """
391    query artifacts($rid: ID!) {
392        repository(rid: $rid) {
393            references {
394                results {
395                    name
396                    artifacts {
397                        results {
398                            id
399                            filename
400                            checksum
401                            size
402                            url
403                        }
404                        cursor
405                    }
406                }
407                cursor
408            }
409        }
410    }
411    """
412
413    func loadArtifacts() async {
414        guard !isLoadingArtifacts else { return }
415        isLoadingArtifacts = true
416        error = nil
417
418        do {
419            let result = try await client.execute(
420                service: service,
421                query: Self.artifactsQuery,
422                variables: ["rid": repository.rid],
423                responseType: ArtifactsResponse.self
424            )
425            // Only include references that have at least one artifact.
426            referenceArtifacts = (result.repository?.references.results ?? [])
427                .filter { !$0.artifacts.results.isEmpty }
428                .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
429        } catch {
430            if isEmptyRepositoryError(error) {
431                referenceArtifacts = []
432            } else {
433                self.error = error.userFacingMessage
434            }
435        }
436
437        isLoadingArtifacts = false
438    }
439}