MR reviewer chips use RemovableChips !127
11 files changed, +10 −185
Layout: unified · split
gitbay.xcodeproj/project.pbxproj +4 −4
| @@ -415,7 +415,7 @@ | ||
| 415 | 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 416 | 416 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; |
| 417 | 417 | CODE_SIGN_STYLE = Automatic; |
| 418 | CURRENT_PROJECT_VERSION = 13; | |
| 418 | CURRENT_PROJECT_VERSION = 12; | |
| 419 | 419 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 420 | 420 | ENABLE_PREVIEWS = YES; |
| 421 | 421 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -431,7 +431,7 @@ | ||
| 431 | 431 | "$(inherited)", |
| 432 | 432 | "@executable_path/Frameworks", |
| 433 | 433 | ); |
| 434 | MARKETING_VERSION = 1.4.0; | |
| 434 | MARKETING_VERSION = 1.3.0; | |
| 435 | 435 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; |
| 436 | 436 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 437 | 437 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -451,7 +451,7 @@ | ||
| 451 | 451 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 452 | 452 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; |
| 453 | 453 | CODE_SIGN_STYLE = Automatic; |
| 454 | CURRENT_PROJECT_VERSION = 13; | |
| 454 | CURRENT_PROJECT_VERSION = 12; | |
| 455 | 455 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 456 | 456 | ENABLE_PREVIEWS = YES; |
| 457 | 457 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -467,7 +467,7 @@ | ||
| 467 | 467 | "$(inherited)", |
| 468 | 468 | "@executable_path/Frameworks", |
| 469 | 469 | ); |
| 470 | MARKETING_VERSION = 1.4.0; | |
| 470 | MARKETING_VERSION = 1.3.0; | |
| 471 | 471 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay; |
| 472 | 472 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 473 | 473 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
gitbay/Builds/BuildListViewModel.swift −6
| @@ -49,12 +49,6 @@ final class BuildListViewModel { | ||
| 49 | 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 | 52 | /// `build jobs <owner/name>` — what the picker offers. A repo |
| 59 | 53 | /// without a CI config answers "not found"; that means no jobs, not |
| 60 | 54 | /// a failure worth showing. |
gitbay/Issues/IssueListViewModel.swift −6
| @@ -64,12 +64,6 @@ final class IssueListViewModel { | ||
| 64 | 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 | 67 | func loadMilestones() async { |
| 74 | 68 | guard milestones.isEmpty else { return } |
| 75 | 69 | milestones = (try? await client.readList( |
gitbay/MRs/MRListViewModel.swift −6
| @@ -50,12 +50,6 @@ final class MRListViewModel { | ||
| 50 | 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 | 53 | func loadMilestones() async { |
| 60 | 54 | guard milestones.isEmpty else { return } |
| 61 | 55 | milestones = (try? await client.readList( |
gitbay/Repos/PagedListModel.swift −11
| @@ -14,17 +14,6 @@ final class PagedListModel<Element: Decodable & Sendable> { | ||
| 14 | 14 | |
| 15 | 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 | 17 | private let client: GitbayClient |
| 29 | 18 | private let pageSize: Int |
| 30 | 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 | 57 | BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } |
| 58 | 58 | } |
| 59 | 59 | .task { |
| 60 | await model.loadIfNeeded() | |
| 60 | await model.load() | |
| 61 | 61 | await model.loadJobs() |
| 62 | 62 | } |
| 63 | 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 | 104 | .task { await createModel.loadTemplates() } |
| 105 | 105 | .refreshable { await model.load() } |
| 106 | 106 | } |
gitbay/Views/MRs/MRListView.swift +1 −1
| @@ -82,7 +82,7 @@ struct MRListView: View { | ||
| 82 | 82 | Task { await model.load() } |
| 83 | 83 | } |
| 84 | 84 | } |
| 85 | .task { await model.loadIfNeeded() } | |
| 85 | .task { await model.load() } | |
| 86 | 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 | 348 | private func reviewersSection(_ mr: MRDetail) -> some View { |
| 349 | 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 | 353 | HStack { |
| 352 | 354 | TextField("Request review", text: $editingReviewer) |
| 353 | 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 | 369 | private var diffSection: some View { |
| 397 | 370 | Section { |
| 398 | 371 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { |
gitbayTests/ListReturnTests.swift deleted −91
| @@ -1,91 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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. | |
| 18 | private let refusal = #"{"protocol_version":1,"error":"denied","exit_code":4}"# | |
| 19 | ||
| 20 | private 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 | |
| 28 | struct 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 | 134 | @Test func reloadDropsTheOldCursorAndItems() async throws { |
| 163 | 135 | let (list, stub) = try makeList() |
| 164 | 136 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) |