import Foundation import Observation /// The repo screen: header from `repo show`, README found in the root /// tree and fetched with `repo cat`. @Observable @MainActor final class RepoDetailViewModel { private(set) var state: LoadState = .loading /// README markdown, when the root tree has one. Absent is normal. private(set) var readme: String? private(set) var readmeName: String? /// Whether this repo is on the account's dashboard. nil until known — /// pin state only exists in the dashboard aggregate. private(set) var isPinned: Bool? /// Open merge request and issue counts from one page each, with a /// trailing "+" when more pages exist. nil when the read failed. private(set) var openMRs: String? private(set) var openIssues: String? private(set) var latestBuild: Build? private(set) var actionError: String? private(set) var working = false private let client: GitbayClient private let path: String init(client: GitbayClient, path: String) { self.client = client self.path = path } func load() async { do { let detail = try await client.read(["repo", "show", path], as: RepoDetail.self) state = .loaded(detail) } catch { state = .from(error) return } await loadPinned() await loadReadme() await loadSummary() } private nonisolated struct Numbered: Decodable, Sendable { let number: Int64 } private func loadSummary() async { async let mrs = try? client.readPage(["mr", "list", path], of: Numbered.self, limit: 50) async let issues = try? client.readPage(["issue", "list", path], of: Numbered.self, limit: 50) async let builds = try? client.readPage(["build", "list", path], of: Build.self, limit: 1) openMRs = Self.countLabel(await mrs) openIssues = Self.countLabel(await issues) latestBuild = await builds?.items.first } private static func countLabel(_ page: GitbayClient.Page?) -> String? { guard let page else { return nil } return "\(page.items.count)\(page.next == nil ? "" : "+")" } private func loadPinned() async { guard let dashboard = try? await client.read(["dashboard"], as: DashboardData.self) else { return } isPinned = dashboard.pinned.contains { $0.path == path } } /// Re-reads `repo show` only, leaving the last good state in place on /// failure. For refreshing after an action performed elsewhere /// (bookmark, watch, mute) without paying for a full screen load. func reloadDetail() async { if let detail = try? await client.read(["repo", "show", path], as: RepoDetail.self) { state = .loaded(detail) } } // MARK: - Management actions func setPinned(_ pinned: Bool) async { await perform(["repo", pinned ? "pin" : "unpin", path]) await loadPinned() } func setArchived(_ archived: Bool) async { await perform(["repo", archived ? "archive" : "unarchive", path]) } private func perform(_ argv: [String]) async { working = true actionError = nil defer { working = false } do { try await client.run(argv) await reloadDetail() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } private func loadReadme() async { // The README is whatever the root tree calls one, matched the way // the web UI matches: README, README.md, readme.org, and so on. guard let listing = try? await client.read( ["repo", "tree", path], as: TreeListing.self ) else { return } let candidate = listing.entries.first { !$0.isDirectory && $0.name.lowercased().hasPrefix("readme") } guard let candidate else { return } guard let file = try? await client.read( ["repo", "cat", path, candidate.name], as: FileContent.self ), !file.binary, let content = file.content else { return } readmeName = candidate.name readme = content } }