gitbay/Issues/IssueDetailViewModel.swift

v1.14.1
gitbay-ios/gitbay/Issues/IssueDetailViewModel.swift history · blame · raw

123 lines · 4389 bytes

  1import Foundation
  2import Observation
  3
  4/// One issue and everything triage needs: comment, close, reopen, labels,
  5/// assignees. Writes reload; refusals surface verbatim.
  6@Observable
  7@MainActor
  8final class IssueDetailViewModel {
  9
 10    private(set) var state: LoadState<IssueDetail> = .loading
 11    private(set) var actionError: String?
 12    private(set) var working = false
 13    /// Open milestones for the picker; fetched on first use.
 14    private(set) var availableMilestones: [Milestone]?
 15    private(set) var colors = LabelColors()
 16    /// Labels rarely change mid-session, and every write reloads via
 17    /// `perform(argv:)`: fetch them once per view model, not on every reload.
 18    private var labelsLoaded = false
 19
 20    private let client: GitbayClient
 21    let repoPath: String
 22    let number: Int64
 23
 24    init(client: GitbayClient, repoPath: String, number: Int64) {
 25        self.client = client
 26        self.repoPath = repoPath
 27        self.number = number
 28    }
 29
 30    private var ref: [String] { [repoPath, String(number)] }
 31
 32    func load() async {
 33        do {
 34            state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
 35        } catch {
 36            state = .from(error)
 37            return
 38        }
 39        guard !labelsLoaded else { return }
 40        labelsLoaded = true
 41        colors = LabelColors(await labelList())
 42    }
 43
 44    private func labelList() async -> [IssueLabel] {
 45        (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
 46    }
 47
 48    /// Both fields are always sent: the title as given, the body over
 49    /// stdin — an empty body clears it, matching the CLI. `format` is
 50    /// appended only when given; omitting it lets the server default.
 51    func edit(title: String, body: String, format: String? = nil) async {
 52        var argv = ["issue", "edit"] + ref + ["--title", title, "--file", "-"]
 53        if let format { argv.append(contentsOf: ["--format", format]) }
 54        await perform(argv, stdin: body)
 55    }
 56
 57    /// nil clears the milestone; the command spells that "none".
 58    func setMilestone(_ title: String?) async {
 59        await perform(["issue", "milestone"] + ref + [title ?? "none"])
 60    }
 61
 62    func loadMilestones() async {
 63        guard availableMilestones == nil else { return }
 64        availableMilestones = (try? await client.readList(
 65            ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self
 66        )) ?? []
 67    }
 68
 69    func comment(_ text: String, format: String? = nil) async {
 70        // stdin is only read when argv carries --file -.
 71        var argv = ["issue", "comment"] + ref + ["--file", "-"]
 72        if let format { argv.append(contentsOf: ["--format", format]) }
 73        await perform(argv, stdin: text)
 74    }
 75
 76    /// React to the issue, or to one of its comments. A reaction the
 77    /// account already gave is taken back.
 78    func react(_ name: String, comment: Int64?) async {
 79        let given = comment == nil
 80            ? state.value?.reactions
 81            : state.value?.comments?.first { $0.commentID == comment }?.reactions
 82        await perform(Reaction.argv(noun: "issue", repo: repoPath, number: number, comment: comment,
 83                                    name: name, remove: Reaction.remove(name, given: given)))
 84    }
 85
 86    func close() async {
 87        await perform(["issue", "close"] + ref)
 88    }
 89
 90    func reopen() async {
 91        await perform(["issue", "reopen"] + ref)
 92    }
 93
 94    func addLabel(_ label: String) async {
 95        await perform(["issue", "label"] + ref + ["--add", label])
 96    }
 97
 98    func removeLabel(_ label: String) async {
 99        await perform(["issue", "label"] + ref + ["--remove", label])
100    }
101
102    func addAssignee(_ user: String) async {
103        await perform(["issue", "assign"] + ref + ["--add", user])
104    }
105
106    func removeAssignee(_ user: String) async {
107        await perform(["issue", "assign"] + ref + ["--remove", user])
108    }
109
110    private func perform(_ argv: [String], stdin: String? = nil) async {
111        working = true
112        actionError = nil
113        defer { working = false }
114        do {
115            try await client.run(argv, stdin: stdin)
116            await load()
117        } catch let error as GitbayError {
118            actionError = error.userFacingMessage
119        } catch {
120            actionError = GitbayError.transport(error).userFacingMessage
121        }
122    }
123}