Commit 1a3fa0c298

1a3fa0c298b9a4fd44c2efeae063fc80a364900a

parent: 60019a10fc

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 18:06 UTC

Paint label chips the colour the web paints them

Layout: unified · split

gitbay/Issues/IssueDetailViewModel.swift +8
@@ -12,6 +12,8 @@ final class IssueDetailViewModel {
12 private(set) var working = false 12 private(set) var working = false
13 /// Open milestones for the picker; fetched on first use. 13 /// Open milestones for the picker; fetched on first use.
14 private(set) var availableMilestones: [Milestone]? 14 private(set) var availableMilestones: [Milestone]?
15 private(set) var labels: [IssueLabel] = []
16 private(set) var colors = LabelColors()
15 17
16 private let client: GitbayClient 18 private let client: GitbayClient
17 let repoPath: String 19 let repoPath: String
@@ -31,6 +33,12 @@ final class IssueDetailViewModel {
31 } catch { 33 } catch {
32 state = .from(error) 34 state = .from(error)
33 } 35 }
36 labels = await labelList()
37 colors = LabelColors(labels)
38 }
39
40 private func labelList() async -> [IssueLabel] {
41 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
34 } 42 }
35 43
36 /// Both fields are always sent: the title as given, the body over 44 /// Both fields are always sent: the title as given, the body over
gitbay/Issues/IssueListViewModel.swift +14
@@ -13,6 +13,7 @@ final class IssueListViewModel {
13 13
14 let list: PagedListModel<Issue> 14 let list: PagedListModel<Issue>
15 let repoPath: String 15 let repoPath: String
16 private let client: GitbayClient
16 var filter: StateFilter = .open { 17 var filter: StateFilter = .open {
17 didSet { 18 didSet {
18 guard filter != oldValue else { return } 19 guard filter != oldValue else { return }
@@ -21,7 +22,14 @@ final class IssueListViewModel {
21 } 22 }
22 } 23 }
23 24
25 /// This repository's labels, loaded beside the issues: the names for
26 /// a picker, the colours for the chips. A failure here is not fatal —
27 /// every chip then derives its colour from its name.
28 private(set) var labels: [IssueLabel] = []
29 private(set) var colors = LabelColors()
30
24 init(client: GitbayClient, repoPath: String) { 31 init(client: GitbayClient, repoPath: String) {
32 self.client = client
25 self.repoPath = repoPath 33 self.repoPath = repoPath
26 list = PagedListModel( 34 list = PagedListModel(
27 client: client, 35 client: client,
@@ -34,6 +42,12 @@ final class IssueListViewModel {
34 42
35 func load() async { 43 func load() async {
36 await list.reload() 44 await list.reload()
45 labels = await labelList()
46 colors = LabelColors(labels)
47 }
48
49 private func labelList() async -> [IssueLabel] {
50 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
37 } 51 }
38 52
39 private func configureList() { 53 private func configureList() {
gitbay/Views/Issues/IssueListView.swift +3 −2
@@ -26,7 +26,7 @@ struct IssueListView: View {
26 26
27 ForEach(model.state.value ?? []) { issue in 27 ForEach(model.state.value ?? []) { issue in
28 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) { 28 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
29 IssueRow(issue: issue) 29 IssueRow(issue: issue, colors: model.colors)
30 } 30 }
31 } 31 }
32 PageFooter(list: model.list) 32 PageFooter(list: model.list)
@@ -70,6 +70,7 @@ struct IssueListView: View {
70 70
71private struct IssueRow: View { 71private struct IssueRow: View {
72 let issue: Issue 72 let issue: Issue
73 let colors: LabelColors
73 74
74 var body: some View { 75 var body: some View {
75 VStack(alignment: .leading, spacing: 4) { 76 VStack(alignment: .leading, spacing: 4) {
@@ -86,7 +87,7 @@ private struct IssueRow: View {
86 .font(.gbSans(.caption2)) 87 .font(.gbSans(.caption2))
87 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) 88 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
88 ForEach(issue.labels ?? [], id: \.self) { label in 89 ForEach(issue.labels ?? [], id: \.self) { label in
89 GBChip(label, .secondary) 90 GBChip(label, colors.color(label))
90 } 91 }
91 Spacer() 92 Spacer()
92 if let assignees = issue.assignees, !assignees.isEmpty { 93 if let assignees = issue.assignees, !assignees.isEmpty {
gitbay/Views/Issues/IssueView.swift +8 −4
@@ -88,7 +88,7 @@ struct IssueView: View {
88 @ViewBuilder 88 @ViewBuilder
89 private func triageSection(_ issue: IssueDetail) -> some View { 89 private func triageSection(_ issue: IssueDetail) -> some View {
90 Section("Labels") { 90 Section("Labels") {
91 labelFlow(issue.labels ?? [], remove: { label in 91 labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
92 Task { await model.removeLabel(label) } 92 Task { await model.removeLabel(label) }
93 }) 93 })
94 HStack { 94 HStack {
@@ -150,7 +150,11 @@ struct IssueView: View {
150 } 150 }
151 151
152 @ViewBuilder 152 @ViewBuilder
153 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View { 153 private func labelFlow(
154 _ items: [String],
155 color: @escaping (String) -> Color = { _ in .secondary },
156 remove: @escaping (String) -> Void
157 ) -> some View {
154 if !items.isEmpty { 158 if !items.isEmpty {
155 ScrollView(.horizontal, showsIndicators: false) { 159 ScrollView(.horizontal, showsIndicators: false) {
156 HStack(spacing: 6) { 160 HStack(spacing: 6) {
@@ -168,8 +172,8 @@ struct IssueView: View {
168 .font(.gbSans(.caption)) 172 .font(.gbSans(.caption))
169 .padding(.horizontal, 8) 173 .padding(.horizontal, 8)
170 .padding(.vertical, 3) 174 .padding(.vertical, 3)
171 .background(Color.secondary.opacity(0.07), in: gbChipShape) 175 .background(color(item).opacity(0.07), in: gbChipShape)
172 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1)) 176 .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1))
173 } 177 }
174 } 178 }
175 } 179 }
gitbayTests/IssueBuildViewModelTests.swift +1 −1
@@ -84,7 +84,7 @@ struct IssueDetailViewModelTests {
84 84
85 await model.comment("triaged from the phone") 85 await model.comment("triaged from the phone")
86 86
87 let write = stub.seen[1] 87 let write = try #require(stub.seen.first { $0.method == "POST" })
88 #expect(write.method == "POST") 88 #expect(write.method == "POST")
89 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) 89 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
90 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"]) 90 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"])
gitbayTests/LabelTests.swift +85
@@ -98,3 +98,88 @@ struct LabelColorsTests {
98 #expect(LabelColors().hex("docs") == "#9a6700") 98 #expect(LabelColors().hex("docs") == "#9a6700")
99 } 99 }
100} 100}
101
102private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
103 let box = StubProtocol.box()
104 let client = GitbayClient(
105 instance: try GitbayInstance(url: "https://gitbay.org"),
106 token: "test-token",
107 session: box.session()
108 )
109 return (client, box)
110}
111
112private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
113 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
114 return try #require(body["argv"] as? [String])
115}
116
117private let issuePageJSON = """
118 {"protocol_version":1,"data":{"items":[\
119 {"number":7,"title":"a bug","state":"open","author":"cmc",\
120 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
121 """
122private let labelListJSON = """
123 {"protocol_version":1,"data":[\
124 {"name":"bug","color":"#cf222e","issues":18},\
125 {"name":"ci","issues":11}],"exit_code":0}
126 """
127
128@MainActor
129struct IssueListColorsTests {
130
131 @Test func loadsLabelsAlongsideTheIssues() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
134 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
135
136 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
137 await model.load()
138
139 #expect(model.state.value?.count == 1)
140 // The names are what MR 2's filter picker will offer.
141 #expect(model.labels.map(\.name) == ["bug", "ci"])
142 #expect(model.colors.hex("bug") == "#cf222e")
143 #expect(model.colors.hex("ci") == "#1a7f37")
144 #expect(stub.seen.contains {
145 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
146 })
147 }
148
149 /// Decoration must not take the screen down with it.
150 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
151 let (client, stub) = try makeClient()
152 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
153 stub.enqueue(.init(status: 403, json: """
154 {"protocol_version":1,"error":"denied","exit_code":4}
155 """, match: "argv=label"))
156
157 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
158 await model.load()
159
160 #expect(model.state.value?.count == 1)
161 #expect(model.labels.isEmpty)
162 // Derived from the name, since nothing was stored.
163 #expect(model.colors.hex("bug") == "#b93a86")
164 }
165}
166
167@MainActor
168struct IssueDetailColorsTests {
169
170 @Test func loadsLabelsAlongsideTheIssue() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: """
173 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
174 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
175 "exit_code":0}
176 """, match: "argv=show"))
177 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
178
179 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
180 await model.load()
181
182 #expect(model.state.value?.number == 7)
183 #expect(model.colors.hex("bug") == "#cf222e")
184 }
185}