gitbay/Discovery/ProfileViewModel.swift

v1.0.6
gitbay-ios/gitbay/Discovery/ProfileViewModel.swift history · blame · raw

148 lines · 5319 bytes

  1import Foundation
  2import Observation
  3
  4/// `profile show <name>` — the whole profile in one read: description,
  5/// links, org membership (or an org's members), the repositories the
  6/// account may see, and a year of activity.
  7@Observable
  8@MainActor
  9final class ProfileViewModel {
 10
 11    nonisolated struct Profile: Decodable, Sendable, Hashable {
 12        let name: String
 13        let kind: String // user | org
 14        let description: String?
 15        let website: String?
 16        /// Long-form profile text, rendered between the header and the
 17        /// activity graph the way the web renders it.
 18        let about: String?
 19        let aboutFormat: String?
 20        let links: [Link]?
 21        let orgs: [Member]?
 22        let members: [Member]?
 23        let repos: [Repo]
 24        let activity: [Day]?
 25        let activityTotal: Int
 26
 27        enum CodingKeys: String, CodingKey {
 28            case name, kind, description, website, about, links, orgs, members, repos, activity
 29            case aboutFormat = "about_format"
 30            case activityTotal = "activity_total"
 31        }
 32
 33        var isOrg: Bool { kind == "org" }
 34
 35        /// The filename the about text is rendered under. The server has
 36        /// none to dispatch on either, so the stored format picks the
 37        /// extension and anything but org is markdown.
 38        var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
 39
 40        /// One of the free-form links a profile carries beside its
 41        /// website. The server caps the list at five.
 42        nonisolated struct Link: Decodable, Sendable, Hashable {
 43            let label: String?
 44            let url: String
 45
 46            /// Labels are optional; a link without one shows its URL.
 47            var title: String {
 48                if let label, !label.isEmpty { return label }
 49                return url
 50            }
 51        }
 52
 53        nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
 54            let name: String
 55            let role: String?
 56            var id: String { name }
 57        }
 58
 59        nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
 60            let path: String
 61            let visibility: String
 62            let description: String?
 63            let archived: Bool?
 64            let topics: [String]?
 65            let license: String?
 66            let defaultBranch: String?
 67            let updated: String?
 68
 69            enum CodingKeys: String, CodingKey {
 70                case path, visibility, description, archived, topics, license, updated
 71                case defaultBranch = "default_branch"
 72            }
 73
 74            var id: String { path }
 75            var isPrivate: Bool { visibility == "private" }
 76            var isArchived: Bool { archived ?? false }
 77            var name: String { String(path.split(separator: "/").last ?? "") }
 78
 79            /// "main · MIT · updated 2026-08-30", the line the web's repo
 80            /// rows carry. Parts the server did not report are left out.
 81            var meta: String {
 82                var parts: [String] = []
 83                if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
 84                if let license, !license.isEmpty { parts.append(license) }
 85                if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
 86                return parts.joined(separator: " · ")
 87            }
 88        }
 89
 90        /// One day's contributions. Days with none are omitted, so the
 91        /// calendar is filled in by the view.
 92        nonisolated struct Day: Decodable, Sendable, Hashable {
 93            let date: String
 94            let count: Int
 95        }
 96    }
 97
 98    private(set) var state: LoadState<Profile> = .loading
 99    private(set) var actionError: String?
100    private(set) var working = false
101
102    private let client: GitbayClient
103    let name: String
104
105    init(client: GitbayClient, name: String) {
106        self.client = client
107        self.name = name
108    }
109
110    func load() async {
111        do {
112            state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
113        } catch {
114            state = .from(error)
115        }
116    }
117
118    /// `org create <name>`.
119    func createOrg(_ name: String) async -> Bool {
120        let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
121        guard !name.isEmpty else { return false }
122        await perform(["org", "create", name])
123        return actionError == nil
124    }
125
126    /// `profile set` — always the caller's own profile. The about text
127    /// travels over stdin, never argv, since it is long-form and may
128    /// carry newlines.
129    func saveProfile(_ edit: ProfileEdit) async -> Bool {
130        guard edit.validationError == nil else { return false }
131        await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin)
132        return actionError == nil
133    }
134
135    private func perform(_ argv: [String], stdin: String? = nil) async {
136        working = true
137        actionError = nil
138        defer { working = false }
139        do {
140            try await client.run(argv, stdin: stdin)
141            await load()
142        } catch let error as GitbayError {
143            actionError = error.userFacingMessage
144        } catch {
145            actionError = GitbayError.transport(error).userFacingMessage
146        }
147    }
148}