Commit 82af2bbc90
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr01-labels.md added +1091
| @@ -0,0 +1,1091 @@ | |||
| 1 | # MR 1: Labels — list, colour, management Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Close the `labels: list, colour` parity row — paint every label chip | ||
| 6 | the colour the web paints it, and add a screen that creates, recolours and | ||
| 7 | removes labels. | ||
| 8 | |||
| 9 | **Architecture:** An `IssueLabel` wire model plus a pure colour function ported | ||
| 10 | from the web's `labelColors`/`clampChip`, so the same label reads the same on | ||
| 11 | both surfaces. Chip colours reach the issue list and issue detail through a | ||
| 12 | `LabelColors` value type each view model builds from one `label list` read — a | ||
| 13 | value, not a shared reference. Each view model also keeps the raw | ||
| 14 | `[IssueLabel]`, because MR 2's filter picker needs the names, not just the | ||
| 15 | colours. The management screen follows the existing milestone screen's | ||
| 16 | model/view-model/view split exactly. | ||
| 17 | |||
| 18 | **Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5, | ||
| 19 | SwiftUI, Swift Testing (not XCTest), `@Observable` view models. | ||
| 20 | |||
| 21 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | ||
| 22 | |||
| 23 | ## Global Constraints | ||
| 24 | |||
| 25 | - Swift 6 language mode, default `MainActor` isolation. Wire models are | ||
| 26 | `nonisolated struct`s. | ||
| 27 | - Swift Testing (`import Testing`, `@Test`, `#expect`, `#require`) — never | ||
| 28 | XCTest. | ||
| 29 | - `gitbayTests` is hermetic and runs offline. Network goes through | ||
| 30 | `StubProtocol`; never add a test that reaches the network. | ||
| 31 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` | ||
| 32 | edit — both are `PBXFileSystemSynchronizedRootGroup`s | ||
| 33 | (`gitbay.xcodeproj/project.pbxproj:39-48`), so Xcode picks them up from the | ||
| 34 | filesystem. | ||
| 35 | - **The model is `IssueLabel`, never `Label`.** SwiftUI's `Label` is used 32 | ||
| 36 | times across `gitbay/`. A type named `Label` in this module would shadow it | ||
| 37 | everywhere and break all of them. | ||
| 38 | - The app adds no capability the CLI lacks. Every command here already exists: | ||
| 39 | `label list <owner/name>`, `label set <owner/name> <label> [--color rrggbb|'']`, | ||
| 40 | `label remove <owner/name> <label>`. | ||
| 41 | - Never mention Claude, LLMs or AI in commits, code comments, or the merge | ||
| 42 | request. No `Co-Authored-By` trailer. | ||
| 43 | - Never commit to `main`. This work happens on a branch and lands as a merge | ||
| 44 | request. | ||
| 45 | |||
| 46 | **Build and test command** (used by every "run the tests" step): | ||
| 47 | |||
| 48 | ```bash | ||
| 49 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 50 | -destination 'platform=iOS Simulator,name=iPhone 17' \ | ||
| 51 | test -only-testing:gitbayTests | ||
| 52 | ``` | ||
| 53 | |||
| 54 | To run one suite, append the suite name: | ||
| 55 | `-only-testing:gitbayTests/LabelPaletteTests`. | ||
| 56 | |||
| 57 | --- | ||
| 58 | |||
| 59 | ## File Structure | ||
| 60 | |||
| 61 | | File | Responsibility | | ||
| 62 | |------|----------------| | ||
| 63 | | `gitbay/Issues/IssueLabel.swift` (create) | `IssueLabel` wire model, `LabelPalette` colour derivation, `LabelColors` lookup | | ||
| 64 | | `gitbay/Issues/LabelListViewModel.swift` (create) | Load, create/recolour, remove — the management screen's state | | ||
| 65 | | `gitbay/Views/Issues/LabelListView.swift` (create) | The management screen | | ||
| 66 | | `gitbayTests/LabelTests.swift` (create) | Every test in this plan | | ||
| 67 | | `gitbay/Views/Repos/RepoRoute.swift` (modify) | `labels(repo:)` case | | ||
| 68 | | `gitbay/ContentView.swift` (modify) | Route to `LabelListView` | | ||
| 69 | | `gitbay/Views/Repos/RepoView.swift` (modify) | Entry point beside Milestones | | ||
| 70 | | `gitbay/Issues/IssueListViewModel.swift` (modify) | Hold `client`, expose `labels` and `colors` | | ||
| 71 | | `gitbay/Issues/IssueDetailViewModel.swift` (modify) | Expose `labels` and `colors` | | ||
| 72 | | `gitbay/Views/Issues/IssueListView.swift` (modify) | Coloured chips | | ||
| 73 | | `gitbay/Views/Issues/IssueView.swift` (modify) | Coloured chips | | ||
| 74 | |||
| 75 | --- | ||
| 76 | |||
| 77 | ### Task 1: Label model and colour derivation | ||
| 78 | |||
| 79 | Pure logic, no network. This is the task worth the most care: the colours have | ||
| 80 | to match the web's exactly, and the golden values below were computed from the | ||
| 81 | web's own algorithm. | ||
| 82 | |||
| 83 | **Background — what the web does** (`krz/gitbay`, | ||
| 84 | `internal/httpd/web.go:1552-1568` and `1512-1545`): | ||
| 85 | |||
| 86 | 1. If the stored colour matches `^#[0-9a-fA-F]{6}$`, use it. | ||
| 87 | 2. Otherwise pick `labelPalette[fnv1a32(name) % 8]`. | ||
| 88 | 3. Either way, run it through `clampChip`, which holds relative luminance | ||
| 89 | between 0.12 and 0.28 by scaling the channels in linear light, keeping the | ||
| 90 | hue. | ||
| 91 | |||
| 92 | Every palette entry already sits inside that window (Y ranges 0.1235 to | ||
| 93 | 0.1862), so a *derived* colour is never adjusted. Clamping only ever changes a | ||
| 94 | *stored* colour. Implement it that way and the palette path stays a plain index. | ||
| 95 | |||
| 96 | `label set` normalises what it stores to lowercase with a leading `#` | ||
| 97 | (`internal/control/label.go:66`), so a stored value in any other shape is | ||
| 98 | treated as unset. | ||
| 99 | |||
| 100 | **Files:** | ||
| 101 | - Create: `gitbay/Issues/IssueLabel.swift` | ||
| 102 | - Test: `gitbayTests/LabelTests.swift` | ||
| 103 | |||
| 104 | **Interfaces:** | ||
| 105 | - Consumes: `UIColor.init(hex: UInt32)` (`gitbay/Theme/Theme.swift:44`) | ||
| 106 | - Produces: | ||
| 107 | - `struct IssueLabel: Decodable, Sendable, Hashable, Identifiable` with | ||
| 108 | `name: String`, `color: String?`, `issues: Int`, `id: String { name }` | ||
| 109 | - `enum LabelPalette` with `static let palette: [String]` and | ||
| 110 | `static func hex(name: String, stored: String?) -> String` | ||
| 111 | - `struct LabelColors` with `init(_ labels: [IssueLabel] = [])`, | ||
| 112 | `func hex(_ name: String) -> String`, `func color(_ name: String) -> Color` | ||
| 113 | |||
| 114 | - [ ] **Step 1: Write the failing tests** | ||
| 115 | |||
| 116 | Create `gitbayTests/LabelTests.swift`: | ||
| 117 | |||
| 118 | ```swift | ||
| 119 | import Foundation | ||
| 120 | import Testing | ||
| 121 | @testable import gitbay | ||
| 122 | |||
| 123 | struct LabelPaletteTests { | ||
| 124 | |||
| 125 | // Golden values computed from the web's own algorithm: FNV-1a 32 of | ||
| 126 | // the name, modulo the eight-colour palette. If these drift, the two | ||
| 127 | // surfaces paint the same label differently. | ||
| 128 | @Test func derivesAStableColourFromTheName() { | ||
| 129 | #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86") | ||
| 130 | #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37") | ||
| 131 | #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700") | ||
| 132 | #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e") | ||
| 133 | #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16") | ||
| 134 | } | ||
| 135 | |||
| 136 | @Test func theSameNameAlwaysDerivesTheSameColour() { | ||
| 137 | #expect(LabelPalette.hex(name: "bug", stored: nil) | ||
| 138 | == LabelPalette.hex(name: "bug", stored: nil)) | ||
| 139 | } | ||
| 140 | |||
| 141 | /// A stored colour already inside the luminance window is used as-is. | ||
| 142 | @Test func keepsAStoredColourThatIsAlreadyLegible() { | ||
| 143 | #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000") | ||
| 144 | #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da") | ||
| 145 | } | ||
| 146 | |||
| 147 | /// Too dark or too bright is scaled back into the window, so a chip | ||
| 148 | /// stays readable as text on both grounds. | ||
| 149 | @Test func clampsAStoredColourOutsideTheWindow() { | ||
| 150 | #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161") | ||
| 151 | #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090") | ||
| 152 | } | ||
| 153 | |||
| 154 | @Test func upperCaseStoredColoursNormaliseDown() { | ||
| 155 | #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000") | ||
| 156 | } | ||
| 157 | |||
| 158 | /// `label set` stores `#rrggbb`. Anything else — an empty string, a | ||
| 159 | /// bare six digits, a name — never reached the store as a colour, so | ||
| 160 | /// it is treated as unset and the name derives one. | ||
| 161 | @Test func anythingThatIsNotAHexColourFallsBackToTheName() { | ||
| 162 | let derived = LabelPalette.hex(name: "bug", stored: nil) | ||
| 163 | #expect(LabelPalette.hex(name: "bug", stored: "") == derived) | ||
| 164 | #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived) | ||
| 165 | #expect(LabelPalette.hex(name: "bug", stored: "red") == derived) | ||
| 166 | #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived) | ||
| 167 | } | ||
| 168 | |||
| 169 | @Test func everyPaletteEntryIsAlreadyLegible() { | ||
| 170 | // No palette colour is ever clamped, so a derived colour is a | ||
| 171 | // plain lookup. If this fails the palette drifted from the web's. | ||
| 172 | for hex in LabelPalette.palette { | ||
| 173 | #expect(LabelPalette.hex(name: "x", stored: hex) == hex) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | struct IssueLabelDecodingTests { | ||
| 179 | |||
| 180 | @Test func decodesAListRow() throws { | ||
| 181 | let json = Data(""" | ||
| 182 | [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}] | ||
| 183 | """.utf8) | ||
| 184 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | ||
| 185 | |||
| 186 | #expect(labels.count == 2) | ||
| 187 | #expect(labels[0].name == "bug") | ||
| 188 | #expect(labels[0].color == "#cf222e") | ||
| 189 | #expect(labels[0].issues == 18) | ||
| 190 | // `color` is omitempty on the wire — absent, not null, when unset. | ||
| 191 | #expect(labels[1].color == nil) | ||
| 192 | #expect(labels[1].issues == 11) | ||
| 193 | } | ||
| 194 | |||
| 195 | @Test func theIdIsTheName() throws { | ||
| 196 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) | ||
| 197 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | ||
| 198 | #expect(labels[0].id == "bug") | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | struct LabelColorsTests { | ||
| 203 | |||
| 204 | @Test func looksUpAStoredColour() { | ||
| 205 | let colors = LabelColors([ | ||
| 206 | IssueLabel(name: "bug", color: "#cf222e", issues: 1), | ||
| 207 | IssueLabel(name: "ci", color: nil, issues: 2), | ||
| 208 | ]) | ||
| 209 | #expect(colors.hex("bug") == "#cf222e") | ||
| 210 | #expect(colors.hex("ci") == "#1a7f37") | ||
| 211 | } | ||
| 212 | |||
| 213 | /// An issue can carry a label the map has not loaded — a stale list, or | ||
| 214 | /// a label added since. It still derives a colour rather than going grey. | ||
| 215 | @Test func anUnknownLabelStillDerivesItsColour() { | ||
| 216 | #expect(LabelColors().hex("docs") == "#9a6700") | ||
| 217 | } | ||
| 218 | } | ||
| 219 | ``` | ||
| 220 | |||
| 221 | - [ ] **Step 2: Run the tests to verify they fail** | ||
| 222 | |||
| 223 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelPaletteTests` | ||
| 224 | |||
| 225 | Expected: the build fails — `cannot find 'LabelPalette' in scope`, `cannot find | ||
| 226 | type 'IssueLabel' in scope`, `cannot find 'LabelColors' in scope`. | ||
| 227 | |||
| 228 | - [ ] **Step 3: Write the implementation** | ||
| 229 | |||
| 230 | Create `gitbay/Issues/IssueLabel.swift`: | ||
| 231 | |||
| 232 | ```swift | ||
| 233 | import SwiftUI | ||
| 234 | |||
| 235 | /// One row of `label list`: the label, the colour it was set to, and how | ||
| 236 | /// many issues carry it. | ||
| 237 | /// | ||
| 238 | /// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is | ||
| 239 | /// used throughout this module and a bare `Label` here would shadow it. | ||
| 240 | nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable { | ||
| 241 | let name: String | ||
| 242 | /// `#rrggbb` when set, absent otherwise — `label set` normalises to | ||
| 243 | /// lowercase with the hash, so any other shape never reached the store. | ||
| 244 | let color: String? | ||
| 245 | let issues: Int | ||
| 246 | |||
| 247 | var id: String { name } | ||
| 248 | } | ||
| 249 | |||
| 250 | /// Chip colours for labels, matching what the web paints | ||
| 251 | /// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same | ||
| 252 | /// label reads the same on both surfaces. | ||
| 253 | nonisolated enum LabelPalette { | ||
| 254 | |||
| 255 | /// Mid-tone hues that stay legible as text on both grounds. Every | ||
| 256 | /// entry already sits inside the window `clamped` holds, so a derived | ||
| 257 | /// colour is a plain lookup and only a stored one is ever adjusted. | ||
| 258 | static let palette = [ | ||
| 259 | "#0969da", "#1a7f37", "#9a6700", "#cf222e", | ||
| 260 | "#8250df", "#b93a86", "#0b6c80", "#bf5b16", | ||
| 261 | ] | ||
| 262 | |||
| 263 | /// The colour the web would paint for this label: the stored one when | ||
| 264 | /// it is a hex colour, otherwise one picked from the palette by name. | ||
| 265 | static func hex(name: String, stored: String?) -> String { | ||
| 266 | guard let stored, let parsed = parse(stored) else { | ||
| 267 | return palette[Int(fnv1a(name) % UInt32(palette.count))] | ||
| 268 | } | ||
| 269 | return clamped(parsed) | ||
| 270 | } | ||
| 271 | |||
| 272 | /// `#rrggbb` to its three channels, or nil for anything else. | ||
| 273 | private static func parse(_ hex: String) -> (Double, Double, Double)? { | ||
| 274 | guard hex.count == 7, hex.hasPrefix("#") else { return nil } | ||
| 275 | let digits = hex.dropFirst() | ||
| 276 | guard digits.allSatisfy(\.isHexDigit), | ||
| 277 | let value = UInt32(digits, radix: 16) else { return nil } | ||
| 278 | return ( | ||
| 279 | Double((value >> 16) & 0xFF) / 255, | ||
| 280 | Double((value >> 8) & 0xFF) / 255, | ||
| 281 | Double(value & 0xFF) / 255 | ||
| 282 | ) | ||
| 283 | } | ||
| 284 | |||
| 285 | /// Hold relative luminance between 0.12 and 0.28, where a chip clears | ||
| 286 | /// 3:1 against white and against the dark ground alike. The hue is | ||
| 287 | /// kept; the channels are scaled in linear light. | ||
| 288 | private static func clamped(_ rgb: (Double, Double, Double)) -> String { | ||
| 289 | let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2)) | ||
| 290 | let y = 0.2126 * r + 0.7152 * g + 0.0722 * b | ||
| 291 | let lo = 0.12, hi = 0.28 | ||
| 292 | if y >= lo, y <= hi { return format(r, g, b) } | ||
| 293 | let target = y < lo ? lo : hi | ||
| 294 | guard y != 0 else { return format(target, target, target) } | ||
| 295 | let k = target / y | ||
| 296 | return format(min(1, r * k), min(1, g * k), min(1, b * k)) | ||
| 297 | } | ||
| 298 | |||
| 299 | private static func linear(_ c: Double) -> Double { | ||
| 300 | c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) | ||
| 301 | } | ||
| 302 | |||
| 303 | private static func format(_ r: Double, _ g: Double, _ b: Double) -> String { | ||
| 304 | func encode(_ v: Double) -> Int { | ||
| 305 | let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055 | ||
| 306 | return Int((s * 255).rounded()) | ||
| 307 | } | ||
| 308 | return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b)) | ||
| 309 | } | ||
| 310 | |||
| 311 | /// FNV-1a, 32-bit. The web hashes the name with it to index the | ||
| 312 | /// palette, so this has to agree bit for bit. | ||
| 313 | private static func fnv1a(_ text: String) -> UInt32 { | ||
| 314 | var hash: UInt32 = 2_166_136_261 | ||
| 315 | for byte in text.utf8 { | ||
| 316 | hash ^= UInt32(byte) | ||
| 317 | hash &*= 16_777_619 | ||
| 318 | } | ||
| 319 | return hash | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | /// The chip colour for every label in one repository. Built from one | ||
| 324 | /// `label list` read; a label it has never heard of still derives a | ||
| 325 | /// colour from its name rather than falling back to grey. | ||
| 326 | nonisolated struct LabelColors: Sendable, Hashable { | ||
| 327 | |||
| 328 | private let stored: [String: String] | ||
| 329 | |||
| 330 | init(_ labels: [IssueLabel] = []) { | ||
| 331 | stored = labels.reduce(into: [:]) { map, label in | ||
| 332 | map[label.name] = label.color ?? "" | ||
| 333 | } | ||
| 334 | } | ||
| 335 | |||
| 336 | func hex(_ name: String) -> String { | ||
| 337 | LabelPalette.hex(name: name, stored: stored[name]) | ||
| 338 | } | ||
| 339 | |||
| 340 | func color(_ name: String) -> Color { | ||
| 341 | guard let value = UInt32(hex(name).dropFirst(), radix: 16) else { | ||
| 342 | return .secondary | ||
| 343 | } | ||
| 344 | return Color(UIColor(hex: value)) | ||
| 345 | } | ||
| 346 | } | ||
| 347 | ``` | ||
| 348 | |||
| 349 | `format` runs on every stored colour, including ones already inside the window. | ||
| 350 | Round-tripping through linear light and back is lossless to the nearest byte, | ||
| 351 | which the `#ff0000` and `#0969da` tests assert. | ||
| 352 | |||
| 353 | - [ ] **Step 4: Run the tests to verify they pass** | ||
| 354 | |||
| 355 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 356 | |||
| 357 | Expected: PASS, including every pre-existing suite. | ||
| 358 | |||
| 359 | - [ ] **Step 5: Commit** | ||
| 360 | |||
| 361 | ```bash | ||
| 362 | git add gitbay/Issues/IssueLabel.swift gitbayTests/LabelTests.swift | ||
| 363 | git commit -m "Label model and the web's chip colours" | ||
| 364 | ``` | ||
| 365 | |||
| 366 | --- | ||
| 367 | |||
| 368 | ### Task 2: Colour the chips on the issue list and issue detail | ||
| 369 | |||
| 370 | Labels render as `GBChip(label, .secondary)` today — one flat colour, and only | ||
| 371 | on the list. This task loads `label list` alongside the issues and paints both | ||
| 372 | screens. | ||
| 373 | |||
| 374 | **Files:** | ||
| 375 | - Modify: `gitbay/Issues/IssueListViewModel.swift` | ||
| 376 | - Modify: `gitbay/Issues/IssueDetailViewModel.swift` | ||
| 377 | - Modify: `gitbay/Views/Issues/IssueListView.swift` (`:29` and `:71-92`) | ||
| 378 | - Modify: `gitbay/Views/Issues/IssueView.swift` (`labelFlow`, called at `:91` | ||
| 379 | for labels and `:109` for assignees) | ||
| 380 | - Test: `gitbayTests/LabelTests.swift` | ||
| 381 | |||
| 382 | **Interfaces:** | ||
| 383 | - Consumes: `LabelColors`, `IssueLabel` (Task 1); `GitbayClient.readList(_:of:)` | ||
| 384 | - Produces: | ||
| 385 | - `IssueListViewModel.labels: [IssueLabel]`, `IssueListViewModel.colors: LabelColors` | ||
| 386 | - `IssueDetailViewModel.labels: [IssueLabel]`, `IssueDetailViewModel.colors: LabelColors` | ||
| 387 | |||
| 388 | Both view models keep the raw `labels` array as well as the derived `colors`. | ||
| 389 | MR 2's label filter picks from `labels`; only the chips need `colors`. | ||
| 390 | |||
| 391 | The colour read is deliberately not fatal: a repository the caller can read | ||
| 392 | issues in but not labels, or a `label list` that fails for any reason, leaves | ||
| 393 | both properties at their empty defaults and every chip derives from its name. | ||
| 394 | An issue list that refuses to load because its decoration failed would be a | ||
| 395 | worse screen than one with derived colours. | ||
| 396 | |||
| 397 | **Note:** `IssueListViewModel` does not currently hold `client` — it passes it | ||
| 398 | into `PagedListModel` and drops it (`IssueListViewModel.swift:25-30`). It needs | ||
| 399 | a stored `client` now. | ||
| 400 | |||
| 401 | - [ ] **Step 1: Write the failing tests** | ||
| 402 | |||
| 403 | Append to `gitbayTests/LabelTests.swift`: | ||
| 404 | |||
| 405 | ```swift | ||
| 406 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 407 | let box = StubProtocol.box() | ||
| 408 | let client = GitbayClient( | ||
| 409 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 410 | token: "test-token", | ||
| 411 | session: box.session() | ||
| 412 | ) | ||
| 413 | return (client, box) | ||
| 414 | } | ||
| 415 | |||
| 416 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 417 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 418 | return try #require(body["argv"] as? [String]) | ||
| 419 | } | ||
| 420 | |||
| 421 | private let issuePageJSON = """ | ||
| 422 | {"protocol_version":1,"data":{"items":[\ | ||
| 423 | {"number":7,"title":"a bug","state":"open","author":"cmc",\ | ||
| 424 | "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0} | ||
| 425 | """ | ||
| 426 | private let labelListJSON = """ | ||
| 427 | {"protocol_version":1,"data":[\ | ||
| 428 | {"name":"bug","color":"#cf222e","issues":18},\ | ||
| 429 | {"name":"ci","issues":11}],"exit_code":0} | ||
| 430 | """ | ||
| 431 | |||
| 432 | @MainActor | ||
| 433 | struct IssueListColorsTests { | ||
| 434 | |||
| 435 | @Test func loadsLabelsAlongsideTheIssues() async throws { | ||
| 436 | let (client, stub) = try makeClient() | ||
| 437 | stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) | ||
| 438 | stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) | ||
| 439 | |||
| 440 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 441 | await model.load() | ||
| 442 | |||
| 443 | #expect(model.state.value?.count == 1) | ||
| 444 | // The names are what MR 2's filter picker will offer. | ||
| 445 | #expect(model.labels.map(\.name) == ["bug", "ci"]) | ||
| 446 | #expect(model.colors.hex("bug") == "#cf222e") | ||
| 447 | #expect(model.colors.hex("ci") == "#1a7f37") | ||
| 448 | #expect(stub.seen.contains { | ||
| 449 | $0.url.query() == "argv=label&argv=list&argv=krz/gitbay" | ||
| 450 | }) | ||
| 451 | } | ||
| 452 | |||
| 453 | /// Decoration must not take the screen down with it. | ||
| 454 | @Test func issuesStillLoadWhenTheLabelReadFails() async throws { | ||
| 455 | let (client, stub) = try makeClient() | ||
| 456 | stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) | ||
| 457 | stub.enqueue(.init(status: 403, json: """ | ||
| 458 | {"protocol_version":1,"error":"denied","exit_code":4} | ||
| 459 | """, match: "argv=label")) | ||
| 460 | |||
| 461 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 462 | await model.load() | ||
| 463 | |||
| 464 | #expect(model.state.value?.count == 1) | ||
| 465 | #expect(model.labels.isEmpty) | ||
| 466 | // Derived from the name, since nothing was stored. | ||
| 467 | #expect(model.colors.hex("bug") == "#b93a86") | ||
| 468 | } | ||
| 469 | } | ||
| 470 | |||
| 471 | @MainActor | ||
| 472 | struct IssueDetailColorsTests { | ||
| 473 | |||
| 474 | @Test func loadsLabelsAlongsideTheIssue() async throws { | ||
| 475 | let (client, stub) = try makeClient() | ||
| 476 | stub.enqueue(.init(status: 200, json: """ | ||
| 477 | {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\ | ||
| 478 | "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ | ||
| 479 | "exit_code":0} | ||
| 480 | """, match: "argv=show")) | ||
| 481 | stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) | ||
| 482 | |||
| 483 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 484 | await model.load() | ||
| 485 | |||
| 486 | #expect(model.state.value?.number == 7) | ||
| 487 | #expect(model.colors.hex("bug") == "#cf222e") | ||
| 488 | } | ||
| 489 | } | ||
| 490 | ``` | ||
| 491 | |||
| 492 | - [ ] **Step 2: Run the tests to verify they fail** | ||
| 493 | |||
| 494 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/IssueListColorsTests` | ||
| 495 | |||
| 496 | Expected: build failure — `value of type 'IssueListViewModel' has no member 'labels'`. | ||
| 497 | |||
| 498 | - [ ] **Step 3: Add `labels` and `colors` to `IssueListViewModel`** | ||
| 499 | |||
| 500 | In `gitbay/Issues/IssueListViewModel.swift`, store the client, add the two | ||
| 501 | properties, and load the labels beside the issues: | ||
| 502 | |||
| 503 | ```swift | ||
| 504 | let list: PagedListModel<Issue> | ||
| 505 | let repoPath: String | ||
| 506 | private let client: GitbayClient | ||
| 507 | |||
| 508 | /// This repository's labels, loaded beside the issues: the names for | ||
| 509 | /// a picker, the colours for the chips. A failure here is not fatal — | ||
| 510 | /// every chip then derives its colour from its name. | ||
| 511 | private(set) var labels: [IssueLabel] = [] | ||
| 512 | private(set) var colors = LabelColors() | ||
| 513 | |||
| 514 | init(client: GitbayClient, repoPath: String) { | ||
| 515 | self.client = client | ||
| 516 | self.repoPath = repoPath | ||
| 517 | list = PagedListModel( | ||
| 518 | client: client, | ||
| 519 | argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue], | ||
| 520 | emptyMessage: "No open issues." | ||
| 521 | ) | ||
| 522 | } | ||
| 523 | |||
| 524 | func load() async { | ||
| 525 | async let issues: Void = list.reload() | ||
| 526 | async let loaded = labelList() | ||
| 527 | _ = await issues | ||
| 528 | labels = await loaded | ||
| 529 | colors = LabelColors(labels) | ||
| 530 | } | ||
| 531 | |||
| 532 | private func labelList() async -> [IssueLabel] { | ||
| 533 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | ||
| 534 | } | ||
| 535 | ``` | ||
| 536 | |||
| 537 | - [ ] **Step 4: Add the same to `IssueDetailViewModel`** | ||
| 538 | |||
| 539 | It already holds `client` and `repoPath` (`IssueDetailViewModel.swift:15-17`), | ||
| 540 | so only the properties and the load change: | ||
| 541 | |||
| 542 | ```swift | ||
| 543 | private(set) var labels: [IssueLabel] = [] | ||
| 544 | private(set) var colors = LabelColors() | ||
| 545 | |||
| 546 | func load() async { | ||
| 547 | async let loaded = labelList() | ||
| 548 | do { | ||
| 549 | state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self)) | ||
| 550 | } catch { | ||
| 551 | state = .from(error) | ||
| 552 | } | ||
| 553 | labels = await loaded | ||
| 554 | colors = LabelColors(labels) | ||
| 555 | } | ||
| 556 | |||
| 557 | private func labelList() async -> [IssueLabel] { | ||
| 558 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | ||
| 559 | } | ||
| 560 | ``` | ||
| 561 | |||
| 562 | - [ ] **Step 5: Run the tests to verify they pass** | ||
| 563 | |||
| 564 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 565 | |||
| 566 | Expected: PASS. | ||
| 567 | |||
| 568 | - [ ] **Step 6: Paint the chips on the issue list** | ||
| 569 | |||
| 570 | In `gitbay/Views/Issues/IssueListView.swift`, pass the map into the row at `:29`: | ||
| 571 | |||
| 572 | ```swift | ||
| 573 | IssueRow(issue: issue, colors: model.colors) | ||
| 574 | ``` | ||
| 575 | |||
| 576 | Add the property to `IssueRow` at `:71`: | ||
| 577 | |||
| 578 | ```swift | ||
| 579 | private struct IssueRow: View { | ||
| 580 | let issue: Issue | ||
| 581 | let colors: LabelColors | ||
| 582 | ``` | ||
| 583 | |||
| 584 | And colour the chip at `:89`: | ||
| 585 | |||
| 586 | ```swift | ||
| 587 | ForEach(issue.labels ?? [], id: \.self) { label in | ||
| 588 | GBChip(label, colors.color(label)) | ||
| 589 | } | ||
| 590 | ``` | ||
| 591 | |||
| 592 | - [ ] **Step 7: Paint the chips on the issue detail** | ||
| 593 | |||
| 594 | `labelFlow` in `gitbay/Views/Issues/IssueView.swift` renders both labels (`:91`) | ||
| 595 | and assignees (`:109`), and only labels are coloured. Give it a colour closure | ||
| 596 | defaulting to the current behaviour: | ||
| 597 | |||
| 598 | ```swift | ||
| 599 | private func labelFlow( | ||
| 600 | _ items: [String], | ||
| 601 | color: (String) -> Color = { _ in .secondary }, | ||
| 602 | remove: @escaping (String) -> Void | ||
| 603 | ) -> some View { | ||
| 604 | ``` | ||
| 605 | |||
| 606 | Inside, replace the chip's `.secondary` with `color(item)`. Then pass the map at | ||
| 607 | the labels call site only: | ||
| 608 | |||
| 609 | ```swift | ||
| 610 | labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in | ||
| 611 | Task { await model.removeLabel(label) } | ||
| 612 | }) | ||
| 613 | ``` | ||
| 614 | |||
| 615 | Leave the assignees call site at `:109` untouched — it takes the default. | ||
| 616 | |||
| 617 | - [ ] **Step 8: Build and run the whole suite** | ||
| 618 | |||
| 619 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 620 | |||
| 621 | Expected: PASS. Then launch the app and confirm the issue list and an issue's | ||
| 622 | detail both show coloured chips against a real repository. | ||
| 623 | |||
| 624 | - [ ] **Step 9: Commit** | ||
| 625 | |||
| 626 | ```bash | ||
| 627 | git add gitbay/Issues/IssueListViewModel.swift gitbay/Issues/IssueDetailViewModel.swift \ | ||
| 628 | gitbay/Views/Issues/IssueListView.swift gitbay/Views/Issues/IssueView.swift \ | ||
| 629 | gitbayTests/LabelTests.swift | ||
| 630 | git commit -m "Paint label chips the colour the web paints them" | ||
| 631 | ``` | ||
| 632 | |||
| 633 | --- | ||
| 634 | |||
| 635 | ### Task 3: The label management view model | ||
| 636 | |||
| 637 | **Files:** | ||
| 638 | - Create: `gitbay/Issues/LabelListViewModel.swift` | ||
| 639 | - Test: `gitbayTests/LabelTests.swift` | ||
| 640 | |||
| 641 | **Interfaces:** | ||
| 642 | - Consumes: `IssueLabel` (Task 1), `LoadState`, `GitbayClient.readList(_:of:)`, | ||
| 643 | `GitbayClient.run(_:stdin:)` | ||
| 644 | - Produces: `LabelListViewModel` with `state: LoadState<[IssueLabel]>`, | ||
| 645 | `actionError: String?`, `working: Bool`, `repoPath: String`, | ||
| 646 | `load()`, `setLabel(_ name: String, color: String?)`, `remove(_ name: String)` | ||
| 647 | |||
| 648 | Follows `MilestoneListViewModel` for the load half and | ||
| 649 | `RepoSettingsViewModel.perform(argv:)` (`:89-101`) for the write half, copied | ||
| 650 | verbatim. It is eight lines, and the two existing copies are not this merge | ||
| 651 | request's to refactor. | ||
| 652 | |||
| 653 | - [ ] **Step 1: Write the failing tests** | ||
| 654 | |||
| 655 | Append to `gitbayTests/LabelTests.swift`: | ||
| 656 | |||
| 657 | ```swift | ||
| 658 | @MainActor | ||
| 659 | struct LabelListViewModelTests { | ||
| 660 | |||
| 661 | private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) { | ||
| 662 | let (client, stub) = try makeClient() | ||
| 663 | stub.enqueue(.init(status: 200, json: labelListJSON)) | ||
| 664 | let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 665 | await model.load() | ||
| 666 | return (model, stub) | ||
| 667 | } | ||
| 668 | |||
| 669 | @Test func listsLabelsWithTheirUseCounts() async throws { | ||
| 670 | let (model, stub) = try await loaded() | ||
| 671 | |||
| 672 | #expect(model.state.value?.map(\.name) == ["bug", "ci"]) | ||
| 673 | #expect(model.state.value?.map(\.issues) == [18, 11]) | ||
| 674 | #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay") | ||
| 675 | } | ||
| 676 | |||
| 677 | @Test func aRepoWithNoLabelsIsAnEmptyState() async throws { | ||
| 678 | let (client, stub) = try makeClient() | ||
| 679 | stub.enqueue(.init(status: 200, json: """ | ||
| 680 | {"protocol_version":1,"exit_code":0} | ||
| 681 | """)) | ||
| 682 | let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 683 | await model.load() | ||
| 684 | |||
| 685 | guard case .empty = model.state else { | ||
| 686 | Testing.Issue.record("expected an empty state, got \(model.state)") | ||
| 687 | return | ||
| 688 | } | ||
| 689 | } | ||
| 690 | |||
| 691 | @Test func settingAColourSendsTheColourFlag() async throws { | ||
| 692 | let (model, stub) = try await loaded() | ||
| 693 | stub.enqueue(.init(status: 200, json: """ | ||
| 694 | {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0} | ||
| 695 | """)) | ||
| 696 | stub.enqueue(.init(status: 200, json: labelListJSON)) | ||
| 697 | |||
| 698 | await model.setLabel("bug", color: "cf222e") | ||
| 699 | |||
| 700 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 701 | #expect(try argvOf(write) | ||
| 702 | == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"]) | ||
| 703 | } | ||
| 704 | |||
| 705 | /// A label created without a colour must not send `--color`, which | ||
| 706 | /// would be "clear it" rather than "leave whatever it has". | ||
| 707 | @Test func creatingWithoutAColourOmitsTheFlag() async throws { | ||
| 708 | let (model, stub) = try await loaded() | ||
| 709 | stub.enqueue(.init(status: 200, json: """ | ||
| 710 | {"protocol_version":1,"data":{"name":"new"},"exit_code":0} | ||
| 711 | """)) | ||
| 712 | stub.enqueue(.init(status: 200, json: labelListJSON)) | ||
| 713 | |||
| 714 | await model.setLabel("new", color: nil) | ||
| 715 | |||
| 716 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 717 | #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"]) | ||
| 718 | } | ||
| 719 | |||
| 720 | /// An empty string is not nil: it clears the colour. | ||
| 721 | @Test func clearingSendsAnEmptyColour() async throws { | ||
| 722 | let (model, stub) = try await loaded() | ||
| 723 | stub.enqueue(.init(status: 200, json: """ | ||
| 724 | {"protocol_version":1,"data":{"name":"bug"},"exit_code":0} | ||
| 725 | """)) | ||
| 726 | stub.enqueue(.init(status: 200, json: labelListJSON)) | ||
| 727 | |||
| 728 | await model.setLabel("bug", color: "") | ||
| 729 | |||
| 730 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 731 | #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""]) | ||
| 732 | } | ||
| 733 | |||
| 734 | @Test func removingSendsTheRemoveCommand() async throws { | ||
| 735 | let (model, stub) = try await loaded() | ||
| 736 | stub.enqueue(.init(status: 200, json: """ | ||
| 737 | {"protocol_version":1,"exit_code":0} | ||
| 738 | """)) | ||
| 739 | stub.enqueue(.init(status: 200, json: labelListJSON)) | ||
| 740 | |||
| 741 | await model.remove("bug") | ||
| 742 | |||
| 743 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 744 | #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"]) | ||
| 745 | } | ||
| 746 | |||
| 747 | @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws { | ||
| 748 | let (model, stub) = try await loaded() | ||
| 749 | stub.enqueue(.init(status: 403, json: """ | ||
| 750 | {"protocol_version":1,"error":"write access required","exit_code":4} | ||
| 751 | """)) | ||
| 752 | |||
| 753 | await model.remove("bug") | ||
| 754 | |||
| 755 | #expect(model.actionError?.isEmpty == false) | ||
| 756 | #expect(model.working == false) | ||
| 757 | } | ||
| 758 | } | ||
| 759 | ``` | ||
| 760 | |||
| 761 | `Testing.Issue.record` is spelled with its module because this app has its own | ||
| 762 | `Issue` model, and `@testable import gitbay` makes the app's win. | ||
| 763 | |||
| 764 | - [ ] **Step 2: Run the tests to verify they fail** | ||
| 765 | |||
| 766 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelListViewModelTests` | ||
| 767 | |||
| 768 | Expected: build failure — `cannot find 'LabelListViewModel' in scope`. | ||
| 769 | |||
| 770 | - [ ] **Step 3: Write the implementation** | ||
| 771 | |||
| 772 | Create `gitbay/Issues/LabelListViewModel.swift`: | ||
| 773 | |||
| 774 | ```swift | ||
| 775 | import Foundation | ||
| 776 | import Observation | ||
| 777 | |||
| 778 | /// `label list <repo>`, plus the two commands that change the set: | ||
| 779 | /// `label set` creates one or recolours it, `label remove` takes it off | ||
| 780 | /// the repository and off every issue that carried it. | ||
| 781 | @Observable | ||
| 782 | @MainActor | ||
| 783 | final class LabelListViewModel { | ||
| 784 | |||
| 785 | private(set) var state: LoadState<[IssueLabel]> = .loading | ||
| 786 | private(set) var actionError: String? | ||
| 787 | private(set) var working = false | ||
| 788 | |||
| 789 | private let client: GitbayClient | ||
| 790 | let repoPath: String | ||
| 791 | |||
| 792 | init(client: GitbayClient, repoPath: String) { | ||
| 793 | self.client = client | ||
| 794 | self.repoPath = repoPath | ||
| 795 | } | ||
| 796 | |||
| 797 | func load() async { | ||
| 798 | state = .loading | ||
| 799 | do { | ||
| 800 | let labels = try await client.readList( | ||
| 801 | ["label", "list", repoPath], of: IssueLabel.self | ||
| 802 | ) | ||
| 803 | state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels) | ||
| 804 | } catch { | ||
| 805 | state = .from(error) | ||
| 806 | } | ||
| 807 | } | ||
| 808 | |||
| 809 | /// Create a label or set its colour. A nil colour omits `--color` | ||
| 810 | /// entirely, which the server reads as "make sure it exists" and | ||
| 811 | /// leaves any colour it already had; an empty string clears it. | ||
| 812 | func setLabel(_ name: String, color: String?) async { | ||
| 813 | var argv = ["label", "set", repoPath, name] | ||
| 814 | if let color { argv.append(contentsOf: ["--color", color]) } | ||
| 815 | await perform(argv) | ||
| 816 | } | ||
| 817 | |||
| 818 | func remove(_ name: String) async { | ||
| 819 | await perform(["label", "remove", repoPath, name]) | ||
| 820 | } | ||
| 821 | |||
| 822 | private func perform(_ argv: [String]) async { | ||
| 823 | working = true | ||
| 824 | actionError = nil | ||
| 825 | defer { working = false } | ||
| 826 | do { | ||
| 827 | try await client.run(argv) | ||
| 828 | await load() | ||
| 829 | } catch let error as GitbayError { | ||
| 830 | actionError = error.userFacingMessage | ||
| 831 | } catch { | ||
| 832 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 833 | } | ||
| 834 | } | ||
| 835 | } | ||
| 836 | ``` | ||
| 837 | |||
| 838 | - [ ] **Step 4: Run the tests to verify they pass** | ||
| 839 | |||
| 840 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 841 | |||
| 842 | Expected: PASS. | ||
| 843 | |||
| 844 | - [ ] **Step 5: Commit** | ||
| 845 | |||
| 846 | ```bash | ||
| 847 | git add gitbay/Issues/LabelListViewModel.swift gitbayTests/LabelTests.swift | ||
| 848 | git commit -m "Label list view model: list, set colour, remove" | ||
| 849 | ``` | ||
| 850 | |||
| 851 | --- | ||
| 852 | |||
| 853 | ### Task 4: The management screen and its route | ||
| 854 | |||
| 855 | **Files:** | ||
| 856 | - Create: `gitbay/Views/Issues/LabelListView.swift` | ||
| 857 | - Modify: `gitbay/Views/Repos/RepoRoute.swift` | ||
| 858 | - Modify: `gitbay/ContentView.swift` (the `RepoRoute` switch, beside | ||
| 859 | `case .milestones` at `:99`) | ||
| 860 | - Modify: `gitbay/Views/Repos/RepoView.swift` (after the Milestones link at `:43-45`) | ||
| 861 | |||
| 862 | **Interfaces:** | ||
| 863 | - Consumes: `LabelListViewModel` (Task 3), `LabelPalette`, `LabelColors`, | ||
| 864 | `IssueLabel` (Task 1), `LoadStateOverlay`, `GBChip`, `GBNotice` | ||
| 865 | - Produces: `RepoRoute.labels(repo: String)`, `LabelListView(client:repo:)` | ||
| 866 | |||
| 867 | `label remove` takes the label off every issue that carried it, so removal is | ||
| 868 | confirmed before it fires. Creating and recolouring are not. | ||
| 869 | |||
| 870 | - [ ] **Step 1: Add the route case** | ||
| 871 | |||
| 872 | In `gitbay/Views/Repos/RepoRoute.swift`, after `case milestones(repo: String)`: | ||
| 873 | |||
| 874 | ```swift | ||
| 875 | case labels(repo: String) | ||
| 876 | ``` | ||
| 877 | |||
| 878 | - [ ] **Step 2: Write the view** | ||
| 879 | |||
| 880 | Create `gitbay/Views/Issues/LabelListView.swift`: | ||
| 881 | |||
| 882 | ```swift | ||
| 883 | import SwiftUI | ||
| 884 | |||
| 885 | /// A repository's labels: their colour, how many issues carry each, and | ||
| 886 | /// the controls to create, recolour and remove them. | ||
| 887 | struct LabelListView: View { | ||
| 888 | |||
| 889 | @State private var model: LabelListViewModel | ||
| 890 | @State private var newLabel = "" | ||
| 891 | @State private var pendingRemoval: IssueLabel? | ||
| 892 | |||
| 893 | init(client: GitbayClient, repo: String) { | ||
| 894 | _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo)) | ||
| 895 | } | ||
| 896 | |||
| 897 | private var colors: LabelColors { LabelColors(model.state.value ?? []) } | ||
| 898 | |||
| 899 | var body: some View { | ||
| 900 | List { | ||
| 901 | if let actionError = model.actionError { | ||
| 902 | Section { GBNotice(actionError) } | ||
| 903 | } | ||
| 904 | |||
| 905 | Section("New label") { | ||
| 906 | HStack { | ||
| 907 | TextField("Label", text: $newLabel) | ||
| 908 | .autocorrectionDisabled() | ||
| 909 | .textInputAutocapitalization(.never) | ||
| 910 | Button { | ||
| 911 | let name = newLabel.trimmingCharacters(in: .whitespaces) | ||
| 912 | newLabel = "" | ||
| 913 | Task { await model.setLabel(name, color: nil) } | ||
| 914 | } label: { | ||
| 915 | Image(systemName: "plus.circle.fill") | ||
| 916 | } | ||
| 917 | .disabled( | ||
| 918 | newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working | ||
| 919 | ) | ||
| 920 | } | ||
| 921 | } | ||
| 922 | |||
| 923 | ForEach(model.state.value ?? []) { label in | ||
| 924 | LabelRow(label: label, color: colors.color(label.name), working: model.working) { | ||
| 925 | hex in | ||
| 926 | Task { await model.setLabel(label.name, color: hex) } | ||
| 927 | } remove: { | ||
| 928 | pendingRemoval = label | ||
| 929 | } | ||
| 930 | } | ||
| 931 | } | ||
| 932 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 933 | .navigationTitle("Labels") | ||
| 934 | .navigationBarTitleDisplayMode(.inline) | ||
| 935 | .task { await model.load() } | ||
| 936 | .refreshable { await model.load() } | ||
| 937 | .confirmationDialog( | ||
| 938 | "Remove \(pendingRemoval?.name ?? "")?", | ||
| 939 | isPresented: .init( | ||
| 940 | get: { pendingRemoval != nil }, | ||
| 941 | set: { if !$0 { pendingRemoval = nil } } | ||
| 942 | ), | ||
| 943 | titleVisibility: .visible | ||
| 944 | ) { | ||
| 945 | Button("Remove", role: .destructive) { | ||
| 946 | guard let label = pendingRemoval else { return } | ||
| 947 | pendingRemoval = nil | ||
| 948 | Task { await model.remove(label.name) } | ||
| 949 | } | ||
| 950 | Button("Cancel", role: .cancel) { pendingRemoval = nil } | ||
| 951 | } message: { | ||
| 952 | // The command takes the label off every issue, not just the set. | ||
| 953 | Text("This takes the label off every issue that carries it.") | ||
| 954 | } | ||
| 955 | } | ||
| 956 | } | ||
| 957 | |||
| 958 | private struct LabelRow: View { | ||
| 959 | let label: IssueLabel | ||
| 960 | let color: Color | ||
| 961 | let working: Bool | ||
| 962 | let recolour: (String) -> Void | ||
| 963 | let remove: () -> Void | ||
| 964 | |||
| 965 | var body: some View { | ||
| 966 | HStack(spacing: 8) { | ||
| 967 | GBChip(label.name, color) | ||
| 968 | Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues") | ||
| 969 | .font(.gbSans(.caption)) | ||
| 970 | .foregroundStyle(.secondary) | ||
| 971 | Spacer() | ||
| 972 | Menu { | ||
| 973 | ForEach(LabelPalette.palette, id: \.self) { hex in | ||
| 974 | Button(hex) { recolour(String(hex.dropFirst())) } | ||
| 975 | } | ||
| 976 | Button("Clear colour") { recolour("") } | ||
| 977 | Button("Remove", role: .destructive) { remove() } | ||
| 978 | } label: { | ||
| 979 | Image(systemName: "ellipsis.circle") | ||
| 980 | } | ||
| 981 | .disabled(working) | ||
| 982 | } | ||
| 983 | } | ||
| 984 | } | ||
| 985 | ``` | ||
| 986 | |||
| 987 | - [ ] **Step 3: Wire the route** | ||
| 988 | |||
| 989 | In `gitbay/ContentView.swift`, in the `RepoRoute` switch after | ||
| 990 | `case .milestones(let repo)` at `:99-100`: | ||
| 991 | |||
| 992 | ```swift | ||
| 993 | case .labels(let repo): | ||
| 994 | LabelListView(client: client, repo: repo) | ||
| 995 | ``` | ||
| 996 | |||
| 997 | In `gitbay/Views/Repos/RepoView.swift`, after the Milestones link at `:43-45`: | ||
| 998 | |||
| 999 | ```swift | ||
| 1000 | NavigationLink(value: RepoRoute.labels(repo: path)) { | ||
| 1001 | Label("Labels", systemImage: "tag") | ||
| 1002 | } | ||
| 1003 | ``` | ||
| 1004 | |||
| 1005 | That `Label` is SwiftUI's, which is exactly why the model is called | ||
| 1006 | `IssueLabel`. | ||
| 1007 | |||
| 1008 | - [ ] **Step 4: Build and check the screen** | ||
| 1009 | |||
| 1010 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 1011 | |||
| 1012 | Expected: PASS. Then launch the app against a real repository and confirm: | ||
| 1013 | labels list with colours and counts; creating one adds it; recolouring repaints | ||
| 1014 | the chip; removing asks first and then removes. | ||
| 1015 | |||
| 1016 | - [ ] **Step 5: Commit** | ||
| 1017 | |||
| 1018 | ```bash | ||
| 1019 | git add gitbay/Views/Issues/LabelListView.swift gitbay/Views/Repos/RepoRoute.swift \ | ||
| 1020 | gitbay/ContentView.swift gitbay/Views/Repos/RepoView.swift | ||
| 1021 | git commit -m "Label management screen" | ||
| 1022 | ``` | ||
| 1023 | |||
| 1024 | --- | ||
| 1025 | |||
| 1026 | ### Task 5: Flip the parity row and open the merge request | ||
| 1027 | |||
| 1028 | The wiki page is updated in the merge request that changes a row — that is what | ||
| 1029 | the page itself requires. | ||
| 1030 | |||
| 1031 | **Files:** | ||
| 1032 | - Modify: `krz/gitbay`, `.gitbay/wiki/Parity.org`, the Issues table | ||
| 1033 | |||
| 1034 | - [ ] **Step 1: Flip the row** | ||
| 1035 | |||
| 1036 | In the Issues table, `labels: list, colour` becomes `yes` in the `ios` column. | ||
| 1037 | Touch no other row — the stale-row corrections are their own merge request on | ||
| 1038 | `krz/gitbay`, not this one. | ||
| 1039 | |||
| 1040 | The wiki lives at `.gitbay/wiki/` on the default branch, so this is an edit to a | ||
| 1041 | file in the repository — a push or `repo commit-file`, not a wiki-specific | ||
| 1042 | command. | ||
| 1043 | |||
| 1044 | - [ ] **Step 2: Run the full suite one more time** | ||
| 1045 | |||
| 1046 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | ||
| 1047 | |||
| 1048 | Expected: PASS. Record the actual count; do not claim a number you have not seen. | ||
| 1049 | |||
| 1050 | - [ ] **Step 3: Open the merge request** | ||
| 1051 | |||
| 1052 | ```bash | ||
| 1053 | gitbay mr create --source labels-colour --target main \ | ||
| 1054 | --title "Labels: list, colour, and a management screen" --file - <<'EOF' | ||
| 1055 | `label list` reaches the app, so chips carry the colour the web paints | ||
| 1056 | them: the stored `#rrggbb` when set, otherwise one picked from the same | ||
| 1057 | eight-colour palette by an FNV-1a hash of the name, clamped to the same | ||
| 1058 | luminance window. The issue list and issue detail both paint them. | ||
| 1059 | |||
| 1060 | A Labels screen under the repository creates a label, sets its colour and | ||
| 1061 | removes one, dispatching `label set` and `label remove`. Removal is | ||
| 1062 | confirmed first, since it takes the label off every issue. | ||
| 1063 | |||
| 1064 | Closes the `labels: list, colour` parity row. | ||
| 1065 | EOF | ||
| 1066 | ``` | ||
| 1067 | |||
| 1068 | --- | ||
| 1069 | |||
| 1070 | ## Notes for whoever executes this | ||
| 1071 | |||
| 1072 | **The colour tests are the ones that matter.** Everything else here is | ||
| 1073 | scaffolding around a screen. If the golden values in Task 1 fail, do not adjust | ||
| 1074 | them to match the code — they were computed from the web's algorithm, and a | ||
| 1075 | mismatch means the port is wrong, not the expectation. | ||
| 1076 | |||
| 1077 | **`Label` is taken.** SwiftUI's `Label` is used 32 times in `gitbay/`. The model | ||
| 1078 | is `IssueLabel` for that reason; do not rename it back. | ||
| 1079 | |||
| 1080 | **`Issue` is ambiguous in tests.** This app has an `Issue` model and Swift | ||
| 1081 | Testing has an `Issue` type for recording failures. Under `@testable import | ||
| 1082 | gitbay` the app's wins, so spell Swift Testing's `Testing.Issue`. | ||
| 1083 | |||
| 1084 | **`label set` without `--color` is not the same as with an empty one.** Omitted | ||
| 1085 | means "make sure this label exists, keep any colour it has". `--color ''` clears | ||
| 1086 | it. The view model models this as `nil` vs `""` and three tests pin the | ||
| 1087 | difference. | ||
| 1088 | |||
| 1089 | **What MR 2 consumes from this.** `IssueListViewModel.labels` is the array MR | ||
| 1090 | 2's filter picker offers, and `IssueListViewModel.colors` paints the chips | ||
| 1091 | inside it. Neither is private; do not narrow them. | ||