gitbay/Repos/RepoDetailViewModel.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Repos/RepoDetailViewModel.swift history · blame · raw

121 lines · 4333 bytes

10 symbols in this file
  1import Foundation
  2import Observation
  3
  4/// The repo screen: header from `repo show`, README found in the root
  5/// tree and fetched with `repo cat`.
  6@Observable
  7@MainActor
  8final class RepoDetailViewModel {
  9
 10    private(set) var state: LoadState<RepoDetail> = .loading
 11    /// README markdown, when the root tree has one. Absent is normal.
 12    private(set) var readme: String?
 13    private(set) var readmeName: String?
 14    /// Whether this repo is on the account's dashboard. nil until known —
 15    /// pin state only exists in the dashboard aggregate.
 16    private(set) var isPinned: Bool?
 17    /// Open merge request and issue counts from one page each, with a
 18    /// trailing "+" when more pages exist. nil when the read failed.
 19    private(set) var openMRs: String?
 20    private(set) var openIssues: String?
 21    private(set) var latestBuild: Build?
 22    private(set) var actionError: String?
 23    private(set) var working = false
 24
 25    private let client: GitbayClient
 26    private let path: String
 27
 28    init(client: GitbayClient, path: String) {
 29        self.client = client
 30        self.path = path
 31    }
 32
 33    func load() async {
 34        do {
 35            let detail = try await client.read(["repo", "show", path], as: RepoDetail.self)
 36            state = .loaded(detail)
 37        } catch {
 38            state = .from(error)
 39            return
 40        }
 41        await loadPinned()
 42        await loadReadme()
 43        await loadSummary()
 44    }
 45
 46    private nonisolated struct Numbered: Decodable, Sendable {
 47        let number: Int64
 48    }
 49
 50    private func loadSummary() async {
 51        async let mrs = try? client.readPage(["mr", "list", path], of: Numbered.self, limit: 50)
 52        async let issues = try? client.readPage(["issue", "list", path], of: Numbered.self, limit: 50)
 53        async let builds = try? client.readPage(["build", "list", path], of: Build.self, limit: 1)
 54        openMRs = Self.countLabel(await mrs)
 55        openIssues = Self.countLabel(await issues)
 56        latestBuild = await builds?.items.first
 57    }
 58
 59    private static func countLabel(_ page: GitbayClient.Page<Numbered>?) -> String? {
 60        guard let page else { return nil }
 61        return "\(page.items.count)\(page.next == nil ? "" : "+")"
 62    }
 63
 64    private func loadPinned() async {
 65        guard let dashboard = try? await client.read(["dashboard"], as: DashboardData.self) else {
 66            return
 67        }
 68        isPinned = dashboard.pinned.contains { $0.path == path }
 69    }
 70
 71    /// Re-reads `repo show` only, leaving the last good state in place on
 72    /// failure. For refreshing after an action performed elsewhere
 73    /// (bookmark, watch, mute) without paying for a full screen load.
 74    func reloadDetail() async {
 75        if let detail = try? await client.read(["repo", "show", path], as: RepoDetail.self) {
 76            state = .loaded(detail)
 77        }
 78    }
 79
 80    // MARK: - Management actions
 81
 82    func setPinned(_ pinned: Bool) async {
 83        await perform(["repo", pinned ? "pin" : "unpin", path])
 84        await loadPinned()
 85    }
 86
 87    func setArchived(_ archived: Bool) async {
 88        await perform(["repo", archived ? "archive" : "unarchive", path])
 89    }
 90
 91    private func perform(_ argv: [String]) async {
 92        working = true
 93        actionError = nil
 94        defer { working = false }
 95        do {
 96            try await client.run(argv)
 97            await reloadDetail()
 98        } catch let error as GitbayError {
 99            actionError = error.userFacingMessage
100        } catch {
101            actionError = GitbayError.transport(error).userFacingMessage
102        }
103    }
104
105    private func loadReadme() async {
106        // The README is whatever the root tree calls one, matched the way
107        // the web UI matches: README, README.md, readme.org, and so on.
108        guard let listing = try? await client.read(
109            ["repo", "tree", path], as: TreeListing.self
110        ) else { return }
111        let candidate = listing.entries.first {
112            !$0.isDirectory && $0.name.lowercased().hasPrefix("readme")
113        }
114        guard let candidate else { return }
115        guard let file = try? await client.read(
116            ["repo", "cat", path, candidate.name], as: FileContent.self
117        ), !file.binary, let content = file.content else { return }
118        readmeName = candidate.name
119        readme = content
120    }
121}