MR reviewer chips use RemovableChips !127

merged merged by cmc on 2026-09-22 21:56 UTC · krz/gitbay-ios:reviewer-chips into main

11 files changed, +10 −185

Layout: unified · split

gitbay.xcodeproj/project.pbxproj +4 −4
@@ -415,7 +415,7 @@
415415 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
416416 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements;
417417 CODE_SIGN_STYLE = Automatic;
418 CURRENT_PROJECT_VERSION = 13;
418 CURRENT_PROJECT_VERSION = 12;
419419 DEVELOPMENT_TEAM = ZCNAX3VL9D;
420420 ENABLE_PREVIEWS = YES;
421421 GENERATE_INFOPLIST_FILE = YES;
@@ -431,7 +431,7 @@
431431 "$(inherited)",
432432 "@executable_path/Frameworks",
433433 );
434 MARKETING_VERSION = 1.4.0;
434 MARKETING_VERSION = 1.3.0;
435435 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
436436 PRODUCT_NAME = "$(TARGET_NAME)";
437437 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -451,7 +451,7 @@
451451 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
452452 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements;
453453 CODE_SIGN_STYLE = Automatic;
454 CURRENT_PROJECT_VERSION = 13;
454 CURRENT_PROJECT_VERSION = 12;
455455 DEVELOPMENT_TEAM = ZCNAX3VL9D;
456456 ENABLE_PREVIEWS = YES;
457457 GENERATE_INFOPLIST_FILE = YES;
@@ -467,7 +467,7 @@
467467 "$(inherited)",
468468 "@executable_path/Frameworks",
469469 );
470 MARKETING_VERSION = 1.4.0;
470 MARKETING_VERSION = 1.3.0;
471471 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
472472 PRODUCT_NAME = "$(TARGET_NAME)";
473473 STRING_CATALOG_GENERATE_SYMBOLS = YES;
gitbay/Builds/BuildListViewModel.swift −6
@@ -49,12 +49,6 @@ final class BuildListViewModel {
4949 await list.reload()
5050 }
5151
52 /// The list's `.task`: reads only while nothing is shown yet.
53 func loadIfNeeded() async {
54 guard list.needsLoad else { return }
55 await load()
56 }
57
5852 /// `build jobs <owner/name>` — what the picker offers. A repo
5953 /// without a CI config answers "not found"; that means no jobs, not
6054 /// a failure worth showing.
gitbay/Issues/IssueListViewModel.swift −6
@@ -64,12 +64,6 @@ final class IssueListViewModel {
6464 colors = LabelColors(labels)
6565 }
6666
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
7367 func loadMilestones() async {
7468 guard milestones.isEmpty else { return }
7569 milestones = (try? await client.readList(
gitbay/MRs/MRListViewModel.swift −6
@@ -50,12 +50,6 @@ final class MRListViewModel {
5050 await list.reload()
5151 }
5252
53 /// The list's `.task`: reads only while nothing is shown yet.
54 func loadIfNeeded() async {
55 guard list.needsLoad else { return }
56 await load()
57 }
58
5953 func loadMilestones() async {
6054 guard milestones.isEmpty else { return }
6155 milestones = (try? await client.readList(
gitbay/Repos/PagedListModel.swift −11
@@ -14,17 +14,6 @@ final class PagedListModel<Element: Decodable & Sendable> {
1414
1515 var hasMore: Bool { next != nil }
1616
17 /// True until a page, or the empty state, is on screen. A list's
18 /// `.task` runs again when a pushed screen is popped; loading only
19 /// while this is true keeps the pages and scroll position the user
20 /// came back to. A failed first page has nothing to keep.
21 var needsLoad: Bool {
22 switch state {
23 case .loaded, .empty: false
24 case .loading, .failed: true
25 }
26 }
27
2817 private let client: GitbayClient
2918 private let pageSize: Int
3019 /// The list command minus paging flags. Set by the owning view model
gitbay/Views/Builds/BuildListView.swift +1 −1
@@ -57,7 +57,7 @@ struct BuildListView: View {
5757 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
5858 }
5959 .task {
60 await model.loadIfNeeded()
60 await model.load()
6161 await model.loadJobs()
6262 }
6363 .refreshable {
gitbay/Views/Issues/IssueListView.swift +1 −1
@@ -100,7 +100,7 @@ struct IssueListView: View {
100100 }
101101 }
102102 }
103 .task { await model.loadIfNeeded() }
103 .task { await model.load() }
104104 .task { await createModel.loadTemplates() }
105105 .refreshable { await model.load() }
106106 }
gitbay/Views/MRs/MRListView.swift +1 −1
@@ -82,7 +82,7 @@ struct MRListView: View {
8282 Task { await model.load() }
8383 }
8484 }
85 .task { await model.loadIfNeeded() }
85 .task { await model.load() }
8686 .refreshable { await model.load() }
8787 }
8888}
gitbay/Views/MRs/MRView.swift +3 −30
@@ -347,7 +347,9 @@ struct MRView: View {
347347
348348 private func reviewersSection(_ mr: MRDetail) -> some View {
349349 Section("Reviewers") {
350 reviewerFlow(mr.reviewRequests)
350 RemovableChips(items: mr.reviewRequests, idPrefix: "mr-reviewer",
351 disabled: model.working,
352 remove: { user in Task { await model.removeReviewRequest(user) } })
351353 HStack {
352354 TextField("Request review", text: $editingReviewer)
353355 .autocorrectionDisabled()
@@ -364,35 +366,6 @@ struct MRView: View {
364366 }
365367 }
366368
367 /// The same add/remove chip shape `IssueView` uses for labels and
368 /// assignees.
369 @ViewBuilder
370 private func reviewerFlow(_ items: [String]) -> some View {
371 if !items.isEmpty {
372 ScrollView(.horizontal, showsIndicators: false) {
373 HStack(spacing: 6) {
374 ForEach(items, id: \.self) { item in
375 HStack(spacing: 3) {
376 Text(item)
377 Button {
378 Task { await model.removeReviewRequest(item) }
379 } label: {
380 Image(systemName: "xmark.circle.fill")
381 .foregroundStyle(.tertiary)
382 }
383 .disabled(model.working)
384 }
385 .font(.gbSans(.caption))
386 .padding(.horizontal, 8)
387 .padding(.vertical, 3)
388 .background(Color.secondary.opacity(0.07), in: gbChipShape)
389 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
390 }
391 }
392 }
393 }
394 }
395
396369 private var diffSection: some View {
397370 Section {
398371 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
gitbayTests/ListReturnTests.swift deleted −91
@@ -1,91 +0,0 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let emptyPage = #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#
16/// A refusal, not a 5xx: the client retries a 5xx once, which would
17/// take the next stub and hide the failure.
18private let refusal = #"{"protocol_version":1,"error":"denied","exit_code":4}"#
19
20private func reads(_ stub: StubProtocol.Box, _ command: String) -> Int {
21 stub.seen.count { $0.url.query()?.contains(command) == true }
22}
23
24/// Returning to a list from a pushed detail screen runs its `.task`
25/// again. `loadIfNeeded()` reads the list only when nothing is shown yet,
26/// so the pages and scroll position the user left are still there.
27@MainActor
28struct ListReturnTests {
29
30 @Test func theIssueListLoadsOnce() async throws {
31 let (client, stub) = try makeClient()
32 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
33 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
34
35 await model.loadIfNeeded()
36 await model.loadIfNeeded()
37
38 #expect(reads(stub, "argv=issue&argv=list") == 1)
39 }
40
41 @Test func theMRListLoadsOnce() async throws {
42 let (client, stub) = try makeClient()
43 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=mr&argv=list"))
44 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
45
46 await model.loadIfNeeded()
47 await model.loadIfNeeded()
48
49 #expect(reads(stub, "argv=mr&argv=list") == 1)
50 }
51
52 @Test func theBuildListLoadsOnce() async throws {
53 let (client, stub) = try makeClient()
54 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=build&argv=list"))
55 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
56
57 await model.loadIfNeeded()
58 await model.loadIfNeeded()
59
60 #expect(reads(stub, "argv=build&argv=list") == 1)
61 }
62
63 @Test func aFailedLoadIsTriedAgainOnReturn() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 403, json: refusal, match: "argv=mr&argv=list"))
66 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=mr&argv=list"))
67 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
68
69 await model.loadIfNeeded()
70 await model.loadIfNeeded()
71
72 #expect(reads(stub, "argv=mr&argv=list") == 2)
73 guard case .empty = model.state else {
74 Issue.record("expected .empty after the retry, got \(model.state)")
75 return
76 }
77 }
78
79 /// Pull-to-refresh still calls `load()`, which always reads.
80 @Test func loadStillReloads() async throws {
81 let (client, stub) = try makeClient()
82 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
83 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue&argv=list"))
84 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
85
86 await model.loadIfNeeded()
87 await model.load()
88
89 #expect(reads(stub, "argv=issue&argv=list") == 2)
90 }
91}
gitbayTests/PagedListTests.swift −28
@@ -131,34 +131,6 @@ struct PagedListModelTests {
131131 }
132132 }
133133
134 /// A list's `.task` runs again when a pushed screen is popped. Only a
135 /// list with nothing to show yet loads there; one showing pages keeps
136 /// them.
137 @Test func needsLoadUntilAPageOrTheEmptyStateIsShown() async throws {
138 let (list, stub) = try makeList()
139 #expect(list.needsLoad)
140
141 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
142 await list.reload()
143 #expect(!list.needsLoad)
144
145 let (empty, emptyStub) = try makeList()
146 emptyStub.enqueue(.init(status: 200, json: page([])))
147 await empty.reload()
148 #expect(!empty.needsLoad)
149 }
150
151 /// A failed first page has nothing on screen to keep, so returning to
152 /// the list tries again.
153 @Test func aFailedFirstPageStillNeedsLoad() async throws {
154 let (list, stub) = try makeList()
155 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"denied","exit_code":4}"#))
156
157 await list.reload()
158
159 #expect(list.needsLoad)
160 }
161
162134 @Test func reloadDropsTheOldCursorAndItems() async throws {
163135 let (list, stub) = try makeList()
164136 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))