krz/hutch

an ios client for sourcehut

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

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