krz/hutch

an ios client for sourcehut

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

v3.6.0: Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift · raw

  1import Foundation
  2
  3private struct HgRepositorySummaryResponse: Decodable, Sendable {
  4    let repository: HgRepositorySummaryPayload?
  5}
  6
  7private struct HgRepositorySummaryPayload: Decodable, Sendable {
  8    let id: Int
  9    let rid: String
 10    let name: String
 11    let description: String?
 12    let visibility: Visibility
 13    let readme: String?
 14    let nonPublishing: Bool?
 15    let tip: HgSummaryTip?
 16    let branches: HgNamedRevisionPage?
 17    let tags: HgNamedRevisionPage?
 18    let bookmarks: HgNamedRevisionPage?
 19}
 20
 21private struct HgSummaryTip: Decodable, Sendable {
 22    let id: String?
 23    let author: String?
 24    let description: String?
 25    let branch: String?
 26    let tags: [String]?
 27
 28    var resolvedRevision: HgRevision? {
 29        guard
 30            let id,
 31            let author,
 32            let description
 33        else {
 34            return nil
 35        }
 36
 37        return HgRevision(
 38            id: id,
 39            author: author,
 40            description: description,
 41            branch: branch,
 42            tags: tags
 43        )
 44    }
 45}
 46
 47private struct HgRevisionLogResponse: Decodable, Sendable {
 48    let repository: HgRevisionLogRepository?
 49}
 50
 51private struct HgRevisionLogRepository: Decodable, Sendable {
 52    let log: HgRevisionPage?
 53}
 54
 55private struct HgReadmeFileResponse: Decodable, Sendable {
 56    let repository: HgReadmeFileRepository?
 57}
 58
 59private struct HgReadmeFileRepository: Decodable, Sendable {
 60    let readme: String?
 61}
 62
 63private struct HgFilesResponse: Decodable, Sendable {
 64    let repository: HgFilesRepository?
 65}
 66
 67private struct HgFilesRepository: Decodable, Sendable {
 68    let files: HgFilePage?
 69}
 70
 71private struct HgFilePage: Decodable, Sendable {
 72    let results: [HgFile]
 73    let cursor: String?
 74}
 75
 76private struct HgNamedRevisionPage: Decodable, Sendable {
 77    let results: [HgNamedRevision]
 78    let cursor: String?
 79
 80    private enum CodingKeys: String, CodingKey {
 81        case results
 82        case cursor
 83    }
 84
 85    init(results: [HgNamedRevision], cursor: String?) {
 86        self.results = results
 87        self.cursor = cursor
 88    }
 89
 90    init(from decoder: any Decoder) throws {
 91        let container = try decoder.container(keyedBy: CodingKeys.self)
 92        self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? []
 93        self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
 94    }
 95}
 96
 97private struct HgCatResponse: Decodable, Sendable {
 98    let repository: HgCatRepository?
 99}
100
101private struct HgCatRepository: Decodable, Sendable {
102    let cat: String?
103}
104
105struct HgRevisionPage: Decodable, Sendable {
106    let results: [HgRevision]
107    let cursor: String?
108}
109
110struct HgRevision: Decodable, Sendable, Identifiable, Hashable {
111    let id: String
112    let author: String
113    let description: String
114    let branch: String?
115    let tags: [String]?
116
117    var displayShortId: String {
118        String(id.prefix(12))
119    }
120
121    var title: String {
122        description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
123    }
124
125    var body: String? {
126        let body = description
127            .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
128            .dropFirst()
129            .first
130            .map(String.init)?
131            .trimmingCharacters(in: .whitespacesAndNewlines)
132        return body?.isEmpty == false ? body : nil
133    }
134
135    var primaryName: String {
136        if let tag = tags?.first, !tag.isEmpty {
137            return tag
138        }
139        if let branch, !branch.isEmpty {
140            return branch
141        }
142        return displayShortId
143    }
144}
145
146struct HgFile: Decodable, Sendable, Hashable, Identifiable {
147    let name: String
148
149    var id: String { name }
150
151    var isDirectory: Bool {
152        name.hasSuffix("/")
153    }
154}
155
156struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable {
157    let name: String
158    let id: String
159
160    var displayShortId: String {
161        String(id.prefix(12))
162    }
163}
164
165@Observable
166@MainActor
167final class HgRepositoryDetailViewModel {
168    enum Tab: String, CaseIterable {
169        case summary = "Summary"
170        case browse = "Browse"
171        case log = "Log"
172        case tags = "Tags"
173        case branches = "Branches"
174        case bookmarks = "Bookmarks"
175    }
176
177    enum ReadmeContent {
178        case html(String)
179        case markdown(String)
180        case org(String)
181        case plainText(String)
182    }
183
184    let repository: RepositorySummary
185    private let client: SRHTClient
186
187    private(set) var summaryLoaded = false
188    private(set) var isLoadingSummary = false
189    private(set) var readmeContent: ReadmeContent?
190    private(set) var readmePath: String?
191    private(set) var nonPublishing = false
192    private(set) var tip: HgRevision?
193    private(set) var branches: [HgNamedRevision] = []
194    private(set) var tags: [HgNamedRevision] = []
195    private(set) var bookmarks: [HgNamedRevision] = []
196
197    private(set) var log: [HgRevision] = []
198    private(set) var isLoadingLog = false
199    private(set) var isLoadingMoreLog = false
200    private var logCursor: String?
201    private var hasMoreLog = true
202
203    private(set) var currentBrowsePath = ""
204    private(set) var pathStack: [String] = []
205    private(set) var files: [HgFile] = []
206    private(set) var fileContent: String?
207    private(set) var selectedFilePath: String?
208    private(set) var isLoadingBrowse = false
209    private(set) var browseRevspec = "tip"
210
211    var error: String?
212
213    init(repository: RepositorySummary, client: SRHTClient) {
214        self.repository = repository
215        self.client = client
216    }
217
218    private static let summaryQuery = """
219    query hgRepositorySummary($rid: ID!) {
220        repository(rid: $rid) {
221            id
222            rid
223            name
224            description
225            visibility
226            readme
227            nonPublishing
228            tip {
229                id
230                author
231                description
232                branch
233                tags
234            }
235            branches {
236                results {
237                    name
238                    id
239                }
240                cursor
241            }
242            tags {
243                results {
244                    name
245                    id
246                }
247                cursor
248            }
249            bookmarks {
250                results {
251                    name
252                    id
253                }
254                cursor
255            }
256        }
257    }
258    """
259
260    private static let logQuery = """
261    query hgRepositoryLog($rid: ID!, $cursor: Cursor) {
262        repository(rid: $rid) {
263            log(cursor: $cursor) {
264                results {
265                    id
266                    author
267                    description
268                    branch
269                    tags
270                }
271                cursor
272            }
273        }
274    }
275    """
276
277    private static func readmeFileQuery(filename: String) -> String {
278        """
279        query hgReadmeFile($rid: ID!) {
280            repository(rid: $rid) {
281                readme: cat(path: "\(filename)", revspec: "tip")
282            }
283        }
284        """
285    }
286
287    private static let readmeFilenames = [
288        "README.md", "README.org", "README.txt", "README",
289        "readme.md", "readme.org"
290    ]
291
292    private static let filesQuery = """
293    query hgFiles($rid: ID!, $path: String!, $revspec: String!) {
294        repository(rid: $rid) {
295            files(path: $path, revspec: $revspec) {
296                results {
297                    name
298                }
299                cursor
300            }
301        }
302    }
303    """
304
305    private static let catQuery = """
306    query hgCat($rid: ID!, $path: String!, $revspec: String!) {
307        repository(rid: $rid) {
308            cat(path: $path, revspec: $revspec)
309        }
310    }
311    """
312
313    func loadSummary() async {
314        guard !isLoadingSummary, !summaryLoaded else { return }
315        isLoadingSummary = true
316        defer { isLoadingSummary = false }
317        error = nil
318
319        do {
320            let result = try await client.execute(
321                service: .hg,
322                query: Self.summaryQuery,
323                variables: ["rid": repository.rid],
324                responseType: HgRepositorySummaryResponse.self
325            )
326
327            guard let repository = result.repository else {
328                summaryLoaded = true
329                return
330            }
331
332            tip = repository.tip?.resolvedRevision
333            branches = repository.branches?.results ?? []
334            tags = repository.tags?.results ?? []
335            bookmarks = repository.bookmarks?.results ?? []
336            nonPublishing = repository.nonPublishing ?? false
337
338            if let html = repository.readme, !html.isEmpty {
339                readmePath = nil
340                readmeContent = .html(html)
341            } else {
342                await loadReadmeFile()
343            }
344
345            summaryLoaded = true
346        } catch {
347            self.error = error.userFacingMessage
348        }
349    }
350
351    func loadLog() async {
352        guard !isLoadingLog else { return }
353        isLoadingLog = true
354        defer { isLoadingLog = false }
355        error = nil
356        logCursor = nil
357        hasMoreLog = true
358
359        do {
360            let page = try await fetchLogPage(cursor: nil)
361            log = page.results
362            logCursor = page.cursor
363            hasMoreLog = page.cursor != nil
364        } catch {
365            if isEmptyRepositoryError(error) {
366                log = []
367                logCursor = nil
368                hasMoreLog = false
369            } else {
370                self.error = error.userFacingMessage
371            }
372        }
373    }
374
375    func loadMoreLogIfNeeded(currentItem: HgRevision) async {
376        guard let last = log.last,
377              last.id == currentItem.id,
378              hasMoreLog,
379              !isLoadingMoreLog else {
380            return
381        }
382
383        isLoadingMoreLog = true
384        defer { isLoadingMoreLog = false }
385
386        do {
387            let page = try await fetchLogPage(cursor: logCursor)
388            log.append(contentsOf: page.results)
389            logCursor = page.cursor
390            hasMoreLog = page.cursor != nil
391        } catch {
392            self.error = error.userFacingMessage
393        }
394    }
395
396    private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage {
397        var variables: [String: any Sendable] = ["rid": repository.rid]
398        if let cursor {
399            variables["cursor"] = cursor
400        }
401
402        let result = try await client.execute(
403            service: .hg,
404            query: Self.logQuery,
405            variables: variables,
406            responseType: HgRevisionLogResponse.self
407        )
408        return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil)
409    }
410
411    private func loadReadmeFile() async {
412        for filename in Self.readmeFilenames {
413            do {
414                let result = try await client.execute(
415                    service: .hg,
416                    query: Self.readmeFileQuery(filename: filename),
417                    variables: ["rid": repository.rid],
418                    responseType: HgReadmeFileResponse.self
419                )
420
421                if let text = result.repository?.readme, !text.isEmpty {
422                    readmePath = filename
423                    if filename.hasSuffix(".md") {
424                        readmeContent = .markdown(text)
425                    } else if filename.hasSuffix(".org") {
426                        readmeContent = .org(text)
427                    } else {
428                        readmeContent = .plainText(text)
429                    }
430                    return
431                }
432            } catch {
433                if isEmptyRepositoryError(error) {
434                    readmeContent = nil
435                    readmePath = nil
436                    return
437                }
438                continue
439            }
440        }
441    }
442
443    func loadBrowseRoot() async {
444        await loadFiles(at: "")
445    }
446
447    func openFile(_ file: HgFile) async {
448        let path = joinedPath(for: file.name)
449        if file.isDirectory {
450            await loadFiles(at: path)
451            return
452        }
453
454        isLoadingBrowse = true
455        defer { isLoadingBrowse = false }
456        error = nil
457
458        do {
459            let result = try await client.execute(
460                service: .hg,
461                query: Self.catQuery,
462                variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
463                responseType: HgCatResponse.self
464            )
465
466            if let text = result.repository?.cat {
467                selectedFilePath = path
468                fileContent = text
469            } else {
470                await loadFiles(at: path)
471            }
472        } catch {
473            self.error = error.userFacingMessage
474        }
475    }
476
477    func navigateToPath(index: Int) async {
478        guard index >= 0, index <= pathStack.count else { return }
479        let targetPath = Array(pathStack.prefix(index)).joined(separator: "/")
480        await loadFiles(at: targetPath)
481    }
482
483    func dismissFileView() {
484        selectedFilePath = nil
485        fileContent = nil
486    }
487
488    func changeBrowseRevspec(_ newRevspec: String) async {
489        guard browseRevspec != newRevspec else { return }
490        browseRevspec = newRevspec
491        await loadBrowseRoot()
492    }
493
494    private func loadFiles(at path: String) async {
495        isLoadingBrowse = true
496        defer { isLoadingBrowse = false }
497        error = nil
498        selectedFilePath = nil
499        fileContent = nil
500
501        do {
502            let result = try await client.execute(
503                service: .hg,
504                query: Self.filesQuery,
505                variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
506                responseType: HgFilesResponse.self
507            )
508
509            currentBrowsePath = path
510            pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
511            files = result.repository?.files?.results ?? []
512        } catch {
513            if isEmptyRepositoryError(error) {
514                currentBrowsePath = path
515                pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
516                files = []
517            } else {
518                self.error = error.userFacingMessage
519            }
520        }
521    }
522
523    private func joinedPath(for name: String) -> String {
524        let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name
525        return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)"
526    }
527
528    private func isEmptyRepositoryError(_ error: Error) -> Bool {
529        error.matchesGraphQLErrorClassification(.notFound)
530            || error.matchesGraphQLErrorClassification(.unknownRevision)
531            || error.matchesGraphQLErrorClassification(.noRows)
532            || error.containsGraphQLErrorMessage("missing")
533    }
534}