gitbay/Repos/RepoDetailViewModel.swift
121 lines · 4333 bytes
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}