gitbay/Issues/IssueListViewModel.swift
97 lines · 3206 bytes
1import Foundation
2import Observation
3
4/// `issue list <repo> --state <s>`, paginated.
5@Observable
6@MainActor
7final class IssueListViewModel {
8
9 let list: PagedListModel<Issue>
10 let repoPath: String
11 private let client: GitbayClient
12 var filter = IssueFilter() {
13 didSet {
14 guard filter != oldValue else { return }
15 configureList()
16 // Only the free-text fields changed: debounce, so typing does
17 // not fire a request per keystroke. Anything else — a picker —
18 // reloads now.
19 var typed = oldValue
20 typed.search = filter.search
21 typed.author = filter.author
22 typed.assignee = filter.assignee
23 if typed == filter {
24 scheduleSearchReload()
25 } else {
26 reloadTask?.cancel()
27 reloadTask = Task { await list.reload() }
28 }
29 }
30 }
31
32 /// This repository's labels, loaded beside the issues: the names for
33 /// a picker, the colours for the chips. A failure here is not fatal —
34 /// every chip then derives its colour from its name.
35 private(set) var labels: [IssueLabel] = []
36 private(set) var colors = LabelColors()
37 /// Labels rarely change mid-session: fetch them once per view model,
38 /// not on every reload.
39 private var labelsLoaded = false
40
41 /// Open and closed milestones for the filter picker; fetched on first use.
42 private(set) var milestones: [Milestone] = []
43 private var reloadTask: Task<Void, Never>?
44
45 init(client: GitbayClient, repoPath: String) {
46 self.client = client
47 self.repoPath = repoPath
48 list = PagedListModel(
49 client: client,
50 argv: ["issue", "list", repoPath] + IssueFilter().flags(),
51 emptyMessage: IssueFilter().emptyMessage
52 )
53 }
54
55 var state: LoadState<[Issue]> { list.state }
56
57 func load() async {
58 reloadTask?.cancel()
59 await list.reload()
60 if case .failed = list.state { return }
61 guard !labelsLoaded else { return }
62 labelsLoaded = true
63 labels = await labelList()
64 colors = LabelColors(labels)
65 }
66
67 /// The list's `.task`: reads only while nothing is shown yet.
68 func loadIfNeeded() async {
69 guard list.needsLoad else { return }
70 await load()
71 }
72
73 func loadMilestones() async {
74 guard milestones.isEmpty else { return }
75 milestones = (try? await client.readList(
76 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
77 )) ?? []
78 }
79
80 private func labelList() async -> [IssueLabel] {
81 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
82 }
83
84 private func scheduleSearchReload() {
85 reloadTask?.cancel()
86 reloadTask = Task {
87 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
88 guard !Task.isCancelled else { return }
89 await list.reload()
90 }
91 }
92
93 private func configureList() {
94 list.argv = ["issue", "list", repoPath] + filter.flags()
95 list.emptyMessage = filter.emptyMessage
96 }
97}