import Foundation import Observation /// `profile show ` — the whole profile in one read: description, /// links, org membership (or an org's members), the repositories the /// account may see, and a year of activity. @Observable @MainActor final class ProfileViewModel { nonisolated struct Profile: Decodable, Sendable, Hashable { let name: String let kind: String // user | org let description: String? let website: String? /// Long-form profile text, rendered between the header and the /// activity graph the way the web renders it. let about: String? let aboutFormat: String? let links: [Link]? let orgs: [Member]? let members: [Member]? let repos: [Repo] let activity: [Day]? let activityTotal: Int enum CodingKeys: String, CodingKey { case name, kind, description, website, about, links, orgs, members, repos, activity case aboutFormat = "about_format" case activityTotal = "activity_total" } var isOrg: Bool { kind == "org" } /// Who may write this profile: a user edits their own, and an org /// is edited by its admins — the rule `org profile` enforces. func canEdit(by username: String?) -> Bool { guard let username else { return false } if isOrg { return (members ?? []).contains { $0.name == username && $0.role == "admin" } } return name == username } /// The filename the about text is rendered under. The server has /// none to dispatch on either, so the stored format picks the /// extension and anything but org is markdown. var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" } /// One of the free-form links a profile carries beside its /// website. The server caps the list at five. nonisolated struct Link: Decodable, Sendable, Hashable { let label: String? let url: String /// Labels are optional; a link without one shows its URL. var title: String { if let label, !label.isEmpty { return label } return url } } nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { let name: String let role: String? var id: String { name } } nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable { let path: String let visibility: String let description: String? let archived: Bool? let topics: [String]? let license: String? let defaultBranch: String? let updated: String? enum CodingKeys: String, CodingKey { case path, visibility, description, archived, topics, license, updated case defaultBranch = "default_branch" } var id: String { path } var isPrivate: Bool { visibility == "private" } var isArchived: Bool { archived ?? false } var name: String { String(path.split(separator: "/").last ?? "") } /// "main · MIT · updated 2026-08-30", the line the web's repo /// rows carry. Parts the server did not report are left out. var meta: String { var parts: [String] = [] if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) } if let license, !license.isEmpty { parts.append(license) } if let updated, !updated.isEmpty { parts.append("updated \(updated)") } return parts.joined(separator: " · ") } } /// One day's contributions. Days with none are omitted, so the /// calendar is filled in by the view. nonisolated struct Day: Decodable, Sendable, Hashable { let date: String let count: Int } } private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let name: String init(client: GitbayClient, name: String) { self.client = client self.name = name } func load() async { do { state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) } catch { state = .from(error) } } /// `org create `. func createOrg(_ name: String) async -> Bool { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return false } await perform(["org", "create", name]) return actionError == nil } /// `profile set` for the caller's own profile, `org profile ` for /// an organization; both take the same flags. The about text travels /// over stdin, never argv, since it is long-form and may carry newlines. func saveProfile(_ edit: ProfileEdit) async -> Bool { guard edit.validationError == nil else { return false } let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] await perform(command + edit.flags(), stdin: edit.aboutStdin) return actionError == nil } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }