gitbay/Discovery/ProfileViewModel.swift
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}