krz/hutch

an ios client for sourcehut

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

v2.3.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: [Reference]
 28    let cursor: String?
 29}
 30
 31private struct ReadmeResponse: Decodable, Sendable {
 32    let repository: ReadmeRepository?
 33}
 34
 35private struct ReadmeRepository: Decodable, Sendable {
 36    let readme: String?
 37}
 38
 39private struct PathResponse: Decodable, Sendable {
 40    let repository: PathRepository?
 41}
 42
 43private struct PathRepository: Decodable, Sendable {
 44    let readme: PathEntry?
 45}
 46
 47private struct PathEntry: Decodable, Sendable {
 48    let object: PathObject?
 49}
 50
 51private struct PathObject: Decodable, Sendable {
 52    let text: String?
 53}
 54
 55private struct ArtifactsResponse: Decodable, Sendable {
 56    let repository: ArtifactsRepository?
 57}
 58
 59private struct ArtifactsRepository: Decodable, Sendable {
 60    let references: ArtifactRefsPage
 61}
 62
 63private struct ArtifactRefsPage: Decodable, Sendable {
 64    let results: [ArtifactRef]
 65    let cursor: String?
 66}
 67
 68private struct ArtifactRef: Decodable, Sendable {
 69    let name: String
 70    let artifacts: ArtifactPage
 71}
 72
 73// MARK: - View Model
 74
 75@Observable
 76@MainActor
 77final class RepositoryDetailViewModel {
 78
 79    enum Tab: String, CaseIterable {
 80        case summary = "Summary"
 81        case tree = "Tree"
 82        case log = "Log"
 83        case refs = "Refs"
 84        case artifacts = "Artifacts"
 85    }
 86
 87    let repository: RepositorySummary
 88    private var service: SRHTService { repository.service }
 89    private let client: SRHTClient
 90
 91    // MARK: - Commit log state
 92
 93    private(set) var commits: [CommitSummary] = []
 94    private(set) var isLoadingCommits = false
 95    private(set) var isLoadingMoreCommits = false
 96    private var commitCursor: String?
 97    private var hasMoreCommits = true
 98
 99    // MARK: - References state
100
101    private(set) var branches: [Reference] = []
102    private(set) var tags: [Reference] = []
103    private(set) var isLoadingRefs = false
104
105    // MARK: - README state
106
107    enum ReadmeContent {
108        case html(String)
109        case markdown(String)
110        case org(String)
111        case plainText(String)
112    }
113
114    private(set) var readmeContent: ReadmeContent?
115    private(set) var readmePath: String?
116    private(set) var isLoadingReadme = false
117    private(set) var readmeLoaded = false
118
119    // MARK: - Artifacts state
120
121    private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
122    private(set) var isLoadingArtifacts = false
123
124    // MARK: - Error
125
126    var error: String?
127
128    init(repository: RepositorySummary, client: SRHTClient) {
129        self.repository = repository
130        self.client = client
131    }
132
133    // MARK: - Commit log
134
135    private static let logQuery = """
136    query repoLog($rid: ID!, $cursor: Cursor) {
137        repository(rid: $rid) {
138            log(cursor: $cursor) {
139                results {
140                    id
141                    shortId
142                    author { name email time }
143                    message
144                }
145                cursor
146            }
147        }
148    }
149    """
150
151    func loadCommits() async {
152        guard !isLoadingCommits else { return }
153        isLoadingCommits = true
154        error = nil
155        commitCursor = nil
156        hasMoreCommits = true
157
158        do {
159            let page = try await fetchCommitPage(cursor: nil)
160            commits = page.results
161            commitCursor = page.cursor
162            hasMoreCommits = page.cursor != nil
163        } catch {
164            self.error = error.userFacingMessage
165        }
166
167        isLoadingCommits = false
168    }
169
170    func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
171        guard let last = commits.last,
172              last.id == currentItem.id,
173              hasMoreCommits,
174              !isLoadingMoreCommits else {
175            return
176        }
177
178        isLoadingMoreCommits = true
179
180        do {
181            let page = try await fetchCommitPage(cursor: commitCursor)
182            commits.append(contentsOf: page.results)
183            commitCursor = page.cursor
184            hasMoreCommits = page.cursor != nil
185        } catch {
186            self.error = error.userFacingMessage
187        }
188
189        isLoadingMoreCommits = false
190    }
191
192    private func fetchCommitPage(cursor: String?) async throws -> LogPage {
193        var variables: [String: any Sendable] = ["rid": repository.rid]
194        if let cursor {
195            variables["cursor"] = cursor
196        }
197        let result: LogResponse
198        do {
199            result = try await client.execute(
200                service: service,
201                query: Self.logQuery,
202                variables: variables,
203                responseType: LogResponse.self
204            )
205        } catch {
206            if isMissingGitReferenceError(error) {
207                return LogPage(results: [], cursor: nil)
208            }
209            throw error
210        }
211        guard let repo = result.repository else {
212            return LogPage(results: [], cursor: nil)
213        }
214        return repo.log
215    }
216
217    // MARK: - References
218
219    private static let refsQuery = """
220    query refs($rid: ID!) {
221        repository(rid: $rid) {
222            references {
223                results { name target }
224                cursor
225            }
226        }
227    }
228    """
229
230    func loadReferences() async {
231        guard !isLoadingRefs else { return }
232        isLoadingRefs = true
233        error = nil
234
235        do {
236            let result = try await client.execute(
237                service: service,
238                query: Self.refsQuery,
239                variables: ["rid": repository.rid],
240                responseType: RefsResponse.self
241            )
242            let allRefs = result.repository?.references.results ?? []
243            branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
244            tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
245        } catch {
246            self.error = error.userFacingMessage
247        }
248
249        isLoadingRefs = false
250    }
251
252    // MARK: - README
253
254    private static let readmeQuery = """
255    query readme($rid: ID!) {
256        repository(rid: $rid) {
257            readme
258        }
259    }
260    """
261
262    private static func readmeFileQuery(filename: String) -> String {
263        """
264        query readmeFile($rid: ID!) {
265            repository(rid: $rid) {
266                readme: path(revspec: "HEAD", path: "\(filename)") {
267                    object {
268                        ... on TextBlob { text }
269                    }
270                }
271            }
272        }
273        """
274    }
275
276    private static let readmeFilenames = [
277        "README.md", "README.org", "README.txt", "README",
278        "readme.md", "readme.org"
279    ]
280
281    func loadReadme() async {
282        guard !isLoadingReadme, !readmeLoaded else { return }
283        isLoadingReadme = true
284        defer { isLoadingReadme = false }
285        error = nil
286
287        do {
288            // Step 1: Check the custom HTML readme set via the web UI
289            let result = try await client.execute(
290                service: service,
291                query: Self.readmeQuery,
292                variables: ["rid": repository.rid],
293                responseType: ReadmeResponse.self
294            )
295            if let html = result.repository?.readme, !html.isEmpty {
296                readmePath = nil
297                readmeContent = .html(html)
298                readmeLoaded = true
299                return
300            }
301
302            // Step 2: Try each README filename sequentially
303            for filename in Self.readmeFilenames {
304                let pathResult: PathResponse
305                do {
306                    pathResult = try await client.execute(
307                        service: service,
308                        query: Self.readmeFileQuery(filename: filename),
309                        variables: ["rid": repository.rid],
310                        responseType: PathResponse.self
311                    )
312                } catch {
313                    if isMissingGitReferenceError(error) {
314                        readmeContent = nil
315                        readmePath = nil
316                        readmeLoaded = true
317                        return
318                    }
319                    throw error
320                }
321                if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
322                    readmePath = filename
323                    if filename.hasSuffix(".md") {
324                        readmeContent = .markdown(text)
325                    } else if filename.hasSuffix(".org") {
326                        readmeContent = .org(text)
327                    } else {
328                        readmeContent = .plainText(text)
329                    }
330                    readmeLoaded = true
331                    return
332                }
333            }
334
335            // Step 3: No readme found
336            readmeContent = nil
337            readmePath = nil
338            readmeLoaded = true
339        } catch {
340            self.error = error.userFacingMessage
341        }
342    }
343
344    private func isMissingGitReferenceError(_ error: Error) -> Bool {
345        guard let srhtError = error as? SRHTError else { return false }
346        guard case .graphQLErrors(let errors) = srhtError else { return false }
347        return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
348    }
349
350    // MARK: - Artifacts
351
352    private static let artifactsQuery = """
353    query artifacts($rid: ID!) {
354        repository(rid: $rid) {
355            references {
356                results {
357                    name
358                    artifacts {
359                        results {
360                            id
361                            filename
362                            checksum
363                            size
364                            url
365                        }
366                        cursor
367                    }
368                }
369                cursor
370            }
371        }
372    }
373    """
374
375    func loadArtifacts() async {
376        guard !isLoadingArtifacts else { return }
377        isLoadingArtifacts = true
378        error = nil
379
380        do {
381            let result = try await client.execute(
382                service: service,
383                query: Self.artifactsQuery,
384                variables: ["rid": repository.rid],
385                responseType: ArtifactsResponse.self
386            )
387            // Only include references that have at least one artifact.
388            referenceArtifacts = (result.repository?.references.results ?? [])
389                .filter { !$0.artifacts.results.isEmpty }
390                .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
391        } catch {
392            self.error = error.userFacingMessage
393        }
394
395        isLoadingArtifacts = false
396    }
397}