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 @@
415 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 415 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
416 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; 416 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements;
417 CODE_SIGN_STYLE = Automatic; 417 CODE_SIGN_STYLE = Automatic;
418 CURRENT_PROJECT_VERSION = 13; 418 CURRENT_PROJECT_VERSION = 12;
419 DEVELOPMENT_TEAM = ZCNAX3VL9D; 419 DEVELOPMENT_TEAM = ZCNAX3VL9D;
420 ENABLE_PREVIEWS = YES; 420 ENABLE_PREVIEWS = YES;
421 GENERATE_INFOPLIST_FILE = YES; 421 GENERATE_INFOPLIST_FILE = YES;
@@ -431,7 +431,7 @@
431 "$(inherited)", 431 "$(inherited)",
432 "@executable_path/Frameworks", 432 "@executable_path/Frameworks",
433 ); 433 );
434 MARKETING_VERSION = 1.4.0; 434 MARKETING_VERSION = 1.3.0;
435 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; 435 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
436 PRODUCT_NAME = "$(TARGET_NAME)"; 436 PRODUCT_NAME = "$(TARGET_NAME)";
437 STRING_CATALOG_GENERATE_SYMBOLS = YES; 437 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -451,7 +451,7 @@
451 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 451 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
452 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; 452 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements;
453 CODE_SIGN_STYLE = Automatic; 453 CODE_SIGN_STYLE = Automatic;
454 CURRENT_PROJECT_VERSION = 13; 454 CURRENT_PROJECT_VERSION = 12;
455 DEVELOPMENT_TEAM = ZCNAX3VL9D; 455 DEVELOPMENT_TEAM = ZCNAX3VL9D;
456 ENABLE_PREVIEWS = YES; 456 ENABLE_PREVIEWS = YES;
457 GENERATE_INFOPLIST_FILE = YES; 457 GENERATE_INFOPLIST_FILE = YES;
@@ -467,7 +467,7 @@
467 "$(inherited)", 467 "$(inherited)",
468 "@executable_path/Frameworks", 468 "@executable_path/Frameworks",
469 ); 469 );
470 MARKETING_VERSION = 1.4.0; 470 MARKETING_VERSION = 1.3.0;
471 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; 471 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
472 PRODUCT_NAME = "$(TARGET_NAME)"; 472 PRODUCT_NAME = "$(TARGET_NAME)";
473 STRING_CATALOG_GENERATE_SYMBOLS = YES; 473 STRING_CATALOG_GENERATE_SYMBOLS = YES;
gitbay/Builds/BuildListViewModel.swift −6
@@ -49,12 +49,6 @@ final class BuildListViewModel {
49 await list.reload() 49 await list.reload()
50 } 50 }
51 51
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
58 /// `build jobs <owner/name>` — what the picker offers. A repo 52 /// `build jobs <owner/name>` — what the picker offers. A repo
59 /// without a CI config answers "not found"; that means no jobs, not 53 /// without a CI config answers "not found"; that means no jobs, not
60 /// a failure worth showing. 54 /// a failure worth showing.
gitbay/Issues/IssueListViewModel.swift −6
@@ -64,12 +64,6 @@ final class IssueListViewModel {
64 colors = LabelColors(labels) 64 colors = LabelColors(labels)
65 } 65 }
66 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 { 67 func loadMilestones() async {
74 guard milestones.isEmpty else { return } 68 guard milestones.isEmpty else { return }
75 milestones = (try? await client.readList( 69 milestones = (try? await client.readList(
gitbay/MRs/MRListViewModel.swift −6
@@ -50,12 +50,6 @@ final class MRListViewModel {
50 await list.reload() 50 await list.reload()
51 } 51 }
52 52
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
59 func loadMilestones() async { 53 func loadMilestones() async {
60 guard milestones.isEmpty else { return } 54 guard milestones.isEmpty else { return }
61 milestones = (try? await client.readList( 55 milestones = (try? await client.readList(
gitbay/Repos/PagedListModel.swift −11
@@ -14,17 +14,6 @@ final class PagedListModel<Element: Decodable & Sendable> {
14 14
15 var hasMore: Bool { next != nil } 15 var hasMore: Bool { next != nil }
16 16
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
28 private let client: GitbayClient 17 private let client: GitbayClient
29 private let pageSize: Int 18 private let pageSize: Int
30 /// The list command minus paging flags. Set by the owning view model 19 /// 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 {
57 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } 57 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
58 } 58 }
59 .task { 59 .task {
60 await model.loadIfNeeded() 60 await model.load()
61 await model.loadJobs() 61 await model.loadJobs()
62 } 62 }
63 .refreshable { 63 .refreshable {
gitbay/Views/Issues/IssueListView.swift +1 −1
@@ -100,7 +100,7 @@ struct IssueListView: View {
100 } 100 }
101 } 101 }
102 } 102 }
103 .task { await model.loadIfNeeded() } 103 .task { await model.load() }
104 .task { await createModel.loadTemplates() } 104 .task { await createModel.loadTemplates() }
105 .refreshable { await model.load() } 105 .refreshable { await model.load() }
106 } 106 }
gitbay/Views/MRs/MRListView.swift +1 −1
@@ -82,7 +82,7 @@ struct MRListView: View {
82 Task { await model.load() } 82 Task { await model.load() }
83 } 83 }
84 } 84 }
85 .task { await model.loadIfNeeded() } 85 .task { await model.load() }
86 .refreshable { await model.load() } 86 .refreshable { await model.load() }
87 } 87 }
88} 88}
gitbay/Views/MRs/MRView.swift +3 −30
@@ -347,7 +347,9 @@ struct MRView: View {
347 347
348 private func reviewersSection(_ mr: MRDetail) -> some View { 348 private func reviewersSection(_ mr: MRDetail) -> some View {
349 Section("Reviewers") { 349 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) } })
351 HStack { 353 HStack {
352 TextField("Request review", text: $editingReviewer) 354 TextField("Request review", text: $editingReviewer)
353 .autocorrectionDisabled() 355 .autocorrectionDisabled()
@@ -364,35 +366,6 @@ struct MRView: View {
364 } 366 }
365 } 367 }
366 368
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
396 private var diffSection: some View { 369 private var diffSection: some View {
397 Section { 370 Section {
398 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { 371 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 {
131 } 131 }
132 } 132 }
133 133
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
162 @Test func reloadDropsTheOldCursorAndItems() async throws { 134 @Test func reloadDropsTheOldCursorAndItems() async throws {
163 let (list, stub) = try makeList() 135 let (list, stub) = try makeList()
164 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) 136 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))