gitbay/Discovery/ProfileViewModel.swift
159 lines · 5897 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 /// Who may write this profile: a user edits their own, and an org
36 /// is edited by its admins — the rule `org profile` enforces.
37 func canEdit(by username: String?) -> Bool {
38 guard let username else { return false }
39 if isOrg {
40 return (members ?? []).contains { $0.name == username && $0.role == "admin" }
41 }
42 return name == username
43 }
44
45 /// The filename the about text is rendered under. The server has
46 /// none to dispatch on either, so the stored format picks the
47 /// extension and anything but org is markdown.
48 var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
49
50 /// One of the free-form links a profile carries beside its
51 /// website. The server caps the list at five.
52 nonisolated struct Link: Decodable, Sendable, Hashable {
53 let label: String?
54 let url: String
55
56 /// Labels are optional; a link without one shows its URL.
57 var title: String {
58 if let label, !label.isEmpty { return label }
59 return url
60 }
61 }
62
63 nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
64 let name: String
65 let role: String?
66 var id: String { name }
67 }
68
69 nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
70 let path: String
71 let visibility: String
72 let description: String?
73 let archived: Bool?
74 let topics: [String]?
75 let license: String?
76 let defaultBranch: String?
77 let updated: String?
78
79 enum CodingKeys: String, CodingKey {
80 case path, visibility, description, archived, topics, license, updated
81 case defaultBranch = "default_branch"
82 }
83
84 var id: String { path }
85 var isPrivate: Bool { visibility == "private" }
86 var isArchived: Bool { archived ?? false }
87 var name: String { String(path.split(separator: "/").last ?? "") }
88
89 /// "main · MIT · updated 2026-08-30", the line the web's repo
90 /// rows carry. Parts the server did not report are left out.
91 var meta: String {
92 var parts: [String] = []
93 if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
94 if let license, !license.isEmpty { parts.append(license) }
95 if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
96 return parts.joined(separator: " · ")
97 }
98 }
99
100 /// One day's contributions. Days with none are omitted, so the
101 /// calendar is filled in by the view.
102 nonisolated struct Day: Decodable, Sendable, Hashable {
103 let date: String
104 let count: Int
105 }
106 }
107
108 private(set) var state: LoadState<Profile> = .loading
109 private(set) var actionError: String?
110 private(set) var working = false
111
112 private let client: GitbayClient
113 let name: String
114
115 init(client: GitbayClient, name: String) {
116 self.client = client
117 self.name = name
118 }
119
120 func load() async {
121 do {
122 state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
123 } catch {
124 state = .from(error)
125 }
126 }
127
128 /// `org create <name>`.
129 func createOrg(_ name: String) async -> Bool {
130 let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
131 guard !name.isEmpty else { return false }
132 await perform(["org", "create", name])
133 return actionError == nil
134 }
135
136 /// `profile set` for the caller's own profile, `org profile <org>` for
137 /// an organization; both take the same flags. The about text travels
138 /// over stdin, never argv, since it is long-form and may carry newlines.
139 func saveProfile(_ edit: ProfileEdit) async -> Bool {
140 guard edit.validationError == nil else { return false }
141 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
142 await perform(command + edit.flags(), stdin: edit.aboutStdin)
143 return actionError == nil
144 }
145
146 private func perform(_ argv: [String], stdin: String? = nil) async {
147 working = true
148 actionError = nil
149 defer { working = false }
150 do {
151 try await client.run(argv, stdin: stdin)
152 await load()
153 } catch let error as GitbayError {
154 actionError = error.userFacingMessage
155 } catch {
156 actionError = GitbayError.transport(error).userFacingMessage
157 }
158 }
159}