Commit bdf7296bc6
Unsigned
Layout: unified · split
gitbay/ContentView.swift +45 −15
| @@ -5,28 +5,52 @@ struct ContentView: View { | |||
| 5 | @Environment(SessionStore.self) private var session | 5 | @Environment(SessionStore.self) private var session |
| 6 | 6 | ||
| 7 | var body: some View { | 7 | var body: some View { |
| 8 | NavigationStack { | 8 | if let client = session.client, let account = session.current { |
| 9 | if let client = session.client { | 9 | TabView { |
| 10 | RepoListView(client: client) | 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { |
| 11 | .id(session.current?.id) // fresh screens on account switch | 11 | NavigationStack { |
| 12 | .navigationDestination(for: RepoRoute.self) { route in | 12 | DashboardView(client: client, username: account.username) |
| 13 | destination(route, client: client) | 13 | .navigationDestinations(client: client) |
| 14 | } | 14 | } |
| 15 | .navigationDestination(for: MRRoute.self) { route in | 15 | } |
| 16 | destination(route, client: client) | 16 | Tab("Repositories", systemImage: "books.vertical") { |
| 17 | NavigationStack { | ||
| 18 | RepoListView(client: client) | ||
| 19 | .navigationDestinations(client: client) | ||
| 17 | } | 20 | } |
| 18 | .navigationDestination(for: IssueRoute.self) { route in | 21 | } |
| 19 | destination(route, client: client) | 22 | } |
| 20 | } | 23 | .id(account.id) // fresh screens on account switch |
| 21 | .navigationDestination(for: BuildRoute.self) { route in | 24 | } else { |
| 22 | destination(route, client: client) | 25 | NavigationStack { |
| 23 | } | ||
| 24 | } else { | ||
| 25 | SignInView() | 26 | SignInView() |
| 26 | } | 27 | } |
| 27 | } | 28 | } |
| 28 | } | 29 | } |
| 29 | 30 | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Every value-routed screen, attachable to any stack root. | ||
| 34 | private struct RouteDestinations: ViewModifier { | ||
| 35 | |||
| 36 | let client: GitbayClient | ||
| 37 | |||
| 38 | func body(content: Content) -> some View { | ||
| 39 | content | ||
| 40 | .navigationDestination(for: RepoRoute.self) { route in | ||
| 41 | destination(route, client: client) | ||
| 42 | } | ||
| 43 | .navigationDestination(for: MRRoute.self) { route in | ||
| 44 | destination(route, client: client) | ||
| 45 | } | ||
| 46 | .navigationDestination(for: IssueRoute.self) { route in | ||
| 47 | destination(route, client: client) | ||
| 48 | } | ||
| 49 | .navigationDestination(for: BuildRoute.self) { route in | ||
| 50 | destination(route, client: client) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 30 | @ViewBuilder | 54 | @ViewBuilder |
| 31 | private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { | 55 | private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { |
| 32 | switch route { | 56 | switch route { |
| @@ -76,6 +100,12 @@ struct ContentView: View { | |||
| 76 | } | 100 | } |
| 77 | } | 101 | } |
| 78 | 102 | ||
| 103 | extension View { | ||
| 104 | func navigationDestinations(client: GitbayClient) -> some View { | ||
| 105 | modifier(RouteDestinations(client: client)) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 79 | #Preview { | 109 | #Preview { |
| 80 | ContentView() | 110 | ContentView() |
| 81 | .environment(SessionStore()) | 111 | .environment(SessionStore()) |
gitbay/Dashboard/DashboardViewModel.swift added +164
| @@ -0,0 +1,164 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// "What needs me": open MRs, issues assigned to me, recent builds. | ||
| 5 | /// | ||
| 6 | /// The API has no cross-repo aggregate yet (a recorded gap), so this | ||
| 7 | /// fans out per repo with bounded concurrency and shows results as they | ||
| 8 | /// arrive. ETag revalidation makes the refresh cheap in bytes, but it is | ||
| 9 | /// still ~2N requests against a 120/min bucket — the shape that argues | ||
| 10 | /// for the one aggregate command; see the filed issue. A 429 stops the | ||
| 11 | /// scan and shows what arrived. | ||
| 12 | @Observable | ||
| 13 | @MainActor | ||
| 14 | final class DashboardViewModel { | ||
| 15 | |||
| 16 | nonisolated struct RepoMR: Sendable, Hashable, Identifiable { | ||
| 17 | let repo: String | ||
| 18 | let mr: MergeRequest | ||
| 19 | var id: String { "\(repo)!\(mr.number)" } | ||
| 20 | } | ||
| 21 | |||
| 22 | nonisolated struct RepoIssue: Sendable, Hashable, Identifiable { | ||
| 23 | let repo: String | ||
| 24 | let issue: Issue | ||
| 25 | var id: String { "\(repo)#\(issue.number)" } | ||
| 26 | } | ||
| 27 | |||
| 28 | nonisolated struct RepoBuild: Sendable, Hashable, Identifiable { | ||
| 29 | let repo: String | ||
| 30 | let build: Build | ||
| 31 | var id: String { "\(repo)#\(build.number)" } | ||
| 32 | } | ||
| 33 | |||
| 34 | private(set) var openMRs: [RepoMR] = [] | ||
| 35 | private(set) var assignedIssues: [RepoIssue] = [] | ||
| 36 | private(set) var recentBuilds: [RepoBuild] = [] | ||
| 37 | private(set) var scanning = false | ||
| 38 | private(set) var scannedRepos = 0 | ||
| 39 | private(set) var totalRepos = 0 | ||
| 40 | /// Set when the scan was cut short (rate limit, error) — partial | ||
| 41 | /// results are on screen and honesty about it beats silence. | ||
| 42 | private(set) var scanNote: String? | ||
| 43 | |||
| 44 | private let client: GitbayClient | ||
| 45 | private let username: String | ||
| 46 | /// In-flight cap. Low on purpose: the phone shares one 120/min bucket | ||
| 47 | /// with everything else the user does. | ||
| 48 | private let width = 4 | ||
| 49 | |||
| 50 | init(client: GitbayClient, username: String) { | ||
| 51 | self.client = client | ||
| 52 | self.username = username | ||
| 53 | } | ||
| 54 | |||
| 55 | func load() async { | ||
| 56 | scanning = true | ||
| 57 | scanNote = nil | ||
| 58 | defer { scanning = false } | ||
| 59 | |||
| 60 | let repos: [RepoSummary] | ||
| 61 | do { | ||
| 62 | repos = try await client.readList(["repo", "list"], of: RepoSummary.self) | ||
| 63 | .filter { !$0.isArchived } | ||
| 64 | } catch { | ||
| 65 | scanNote = LoadState<Never>.from(error).failureMessage | ||
| 66 | return | ||
| 67 | } | ||
| 68 | totalRepos = repos.count | ||
| 69 | scannedRepos = 0 | ||
| 70 | |||
| 71 | var mrs: [RepoMR] = [] | ||
| 72 | var issues: [RepoIssue] = [] | ||
| 73 | var builds: [RepoBuild] = [] | ||
| 74 | |||
| 75 | // Fan out width-at-a-time; each repo costs up to three reads. | ||
| 76 | var iterator = repos.makeIterator() | ||
| 77 | var stop = false | ||
| 78 | while !stop { | ||
| 79 | var batch: [RepoSummary] = [] | ||
| 80 | for _ in 0..<width { | ||
| 81 | if let next = iterator.next() { batch.append(next) } | ||
| 82 | } | ||
| 83 | if batch.isEmpty { break } | ||
| 84 | |||
| 85 | await withTaskGroup(of: RepoScan?.self) { group in | ||
| 86 | for repo in batch { | ||
| 87 | group.addTask { [client, username] in | ||
| 88 | await Self.scan(repo.path, client: client, username: username) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | for await result in group { | ||
| 92 | scannedRepos += 1 | ||
| 93 | guard let result else { | ||
| 94 | stop = true | ||
| 95 | continue | ||
| 96 | } | ||
| 97 | mrs.append(contentsOf: result.mrs.map { RepoMR(repo: result.repo, mr: $0) }) | ||
| 98 | issues.append(contentsOf: result.issues.map { RepoIssue(repo: result.repo, issue: $0) }) | ||
| 99 | builds.append(contentsOf: result.builds.map { RepoBuild(repo: result.repo, build: $0) }) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | publish(mrs: mrs, issues: issues, builds: builds) | ||
| 103 | } | ||
| 104 | if stop { | ||
| 105 | scanNote = "Rate limited part way — showing \(scannedRepos) of \(totalRepos) repositories." | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | private func publish(mrs: [RepoMR], issues: [RepoIssue], builds: [RepoBuild]) { | ||
| 110 | openMRs = mrs.sorted { $0.mr.createdAt > $1.mr.createdAt } | ||
| 111 | assignedIssues = issues.sorted { $0.issue.createdAt > $1.issue.createdAt } | ||
| 112 | recentBuilds = Array(builds.sorted { $0.build.createdAt > $1.build.createdAt }.prefix(10)) | ||
| 113 | } | ||
| 114 | |||
| 115 | private nonisolated struct RepoScan: Sendable { | ||
| 116 | let repo: String | ||
| 117 | let mrs: [MergeRequest] | ||
| 118 | let issues: [Issue] | ||
| 119 | let builds: [Build] | ||
| 120 | } | ||
| 121 | |||
| 122 | /// One repo's slice of the dashboard. nil means "stop the scan" — | ||
| 123 | /// the rate limiter said so. | ||
| 124 | private nonisolated static func scan( | ||
| 125 | _ repo: String, | ||
| 126 | client: GitbayClient, | ||
| 127 | username: String | ||
| 128 | ) async -> RepoScan? { | ||
| 129 | do { | ||
| 130 | let mrs = try await client.readList( | ||
| 131 | ["mr", "list", repo, "--state", "open"], of: MergeRequest.self | ||
| 132 | ) | ||
| 133 | let issues = try await client.readList( | ||
| 134 | ["issue", "list", repo, "--state", "open"], of: Issue.self | ||
| 135 | ).filter { $0.assignees?.contains(username) == true } | ||
| 136 | // Builds only where something else is happening; a third call | ||
| 137 | // per silent repo is what the missing aggregate would spare. | ||
| 138 | var builds: [Build] = [] | ||
| 139 | if !mrs.isEmpty || !issues.isEmpty { | ||
| 140 | builds = Array(try await client.readList( | ||
| 141 | ["build", "list", repo], of: Build.self | ||
| 142 | ).prefix(3)) | ||
| 143 | } | ||
| 144 | return RepoScan(repo: repo, mrs: mrs, issues: issues, builds: builds) | ||
| 145 | } catch let error as GitbayError { | ||
| 146 | if case .rateLimited = error { return nil } | ||
| 147 | // One repo failing (permissions changed, whatever) should not | ||
| 148 | // hide the rest of the dashboard. | ||
| 149 | return RepoScan(repo: repo, mrs: [], issues: [], builds: []) | ||
| 150 | } catch { | ||
| 151 | return RepoScan(repo: repo, mrs: [], issues: [], builds: []) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | extension LoadState { | ||
| 157 | /// The message of a `.failed`/`.empty`, for callers that only need text. | ||
| 158 | var failureMessage: String? { | ||
| 159 | switch self { | ||
| 160 | case .failed(let message), .empty(let message): message | ||
| 161 | default: nil | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
gitbay/Views/Dashboard/DashboardView.swift added +114
| @@ -0,0 +1,114 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// What needs me, across every repo I can reach. | ||
| 4 | struct DashboardView: View { | ||
| 5 | |||
| 6 | @State private var model: DashboardViewModel | ||
| 7 | |||
| 8 | init(client: GitbayClient, username: String) { | ||
| 9 | _model = State(initialValue: DashboardViewModel(client: client, username: username)) | ||
| 10 | } | ||
| 11 | |||
| 12 | var body: some View { | ||
| 13 | List { | ||
| 14 | if let note = model.scanNote { | ||
| 15 | Section { | ||
| 16 | Label(note, systemImage: "exclamationmark.triangle") | ||
| 17 | .font(.caption) | ||
| 18 | .foregroundStyle(.orange) | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | Section("Needs review") { | ||
| 23 | if model.openMRs.isEmpty { | ||
| 24 | emptyRow(model.scanning ? "Scanning…" : "No open merge requests.") | ||
| 25 | } else { | ||
| 26 | ForEach(model.openMRs) { entry in | ||
| 27 | NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) { | ||
| 28 | VStack(alignment: .leading, spacing: 2) { | ||
| 29 | Text(entry.repo) | ||
| 30 | .font(.caption) | ||
| 31 | .foregroundStyle(.secondary) | ||
| 32 | MRRow(mr: entry.mr) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | Section("Assigned to me") { | ||
| 40 | if model.assignedIssues.isEmpty { | ||
| 41 | emptyRow(model.scanning ? "Scanning…" : "No assigned issues.") | ||
| 42 | } else { | ||
| 43 | ForEach(model.assignedIssues) { entry in | ||
| 44 | NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) { | ||
| 45 | VStack(alignment: .leading, spacing: 2) { | ||
| 46 | Text(entry.repo) | ||
| 47 | .font(.caption) | ||
| 48 | .foregroundStyle(.secondary) | ||
| 49 | HStack(spacing: 6) { | ||
| 50 | Text("#\(entry.issue.number)") | ||
| 51 | .font(.caption.monospaced()) | ||
| 52 | .foregroundStyle(.secondary) | ||
| 53 | Text(entry.issue.title) | ||
| 54 | .font(.subheadline.weight(.medium)) | ||
| 55 | .lineLimit(2) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | Section("Recent builds") { | ||
| 64 | if model.recentBuilds.isEmpty { | ||
| 65 | emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.") | ||
| 66 | } else { | ||
| 67 | ForEach(model.recentBuilds) { entry in | ||
| 68 | NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) { | ||
| 69 | HStack(spacing: 8) { | ||
| 70 | Image(systemName: entry.build.status == "success" | ||
| 71 | ? "checkmark.circle.fill" | ||
| 72 | : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted") | ||
| 73 | .foregroundStyle(entry.build.status == "success" | ||
| 74 | ? .green : entry.build.status == "failure" ? .red : .orange) | ||
| 75 | VStack(alignment: .leading, spacing: 2) { | ||
| 76 | Text(entry.repo) | ||
| 77 | .font(.caption) | ||
| 78 | .foregroundStyle(.secondary) | ||
| 79 | Text("#\(entry.build.number) \(entry.build.job)") | ||
| 80 | .font(.subheadline) | ||
| 81 | } | ||
| 82 | Spacer() | ||
| 83 | Text(entry.build.createdAt, format: .relative(presentation: .named)) | ||
| 84 | .font(.caption) | ||
| 85 | .foregroundStyle(.tertiary) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | if model.scanning { | ||
| 93 | Section { | ||
| 94 | HStack { | ||
| 95 | ProgressView() | ||
| 96 | Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…") | ||
| 97 | .font(.caption) | ||
| 98 | .foregroundStyle(.secondary) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | .navigationTitle("Dashboard") | ||
| 104 | .toolbar { AccountMenu() } | ||
| 105 | .task { await model.load() } | ||
| 106 | .refreshable { await model.load() } | ||
| 107 | } | ||
| 108 | |||
| 109 | private func emptyRow(_ text: String) -> some View { | ||
| 110 | Text(text) | ||
| 111 | .font(.subheadline) | ||
| 112 | .foregroundStyle(.secondary) | ||
| 113 | } | ||
| 114 | } | ||
gitbay/Views/SignInView.swift +2
| @@ -35,6 +35,8 @@ struct SignInView: View { | |||
| 35 | .autocorrectionDisabled() | 35 | .autocorrectionDisabled() |
| 36 | .textInputAutocapitalization(.never) | 36 | .textInputAutocapitalization(.never) |
| 37 | .focused($tokenFieldFocused) | 37 | .focused($tokenFieldFocused) |
| 38 | .submitLabel(.go) | ||
| 39 | .onSubmit { signIn() } | ||
| 38 | } header: { | 40 | } header: { |
| 39 | Text("Token") | 41 | Text("Token") |
| 40 | } footer: { | 42 | } footer: { |
gitbayTests/DashboardViewModelTests.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 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 twoRepoList = """ | ||
| 16 | {"protocol_version":1,"data":[\ | ||
| 17 | {"path":"krz/gitbay","visibility":"public"},\ | ||
| 18 | {"path":"krz/dust","visibility":"public","archived":true},\ | ||
| 19 | {"path":"krz/hutch","visibility":"public"}\ | ||
| 20 | ],"exit_code":0} | ||
| 21 | """ | ||
| 22 | |||
| 23 | private let openMRJSON = """ | ||
| 24 | {"protocol_version":1,"data":[\ | ||
| 25 | {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\ | ||
| 26 | "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\ | ||
| 27 | ],"exit_code":0} | ||
| 28 | """ | ||
| 29 | |||
| 30 | private let assignedIssueJSON = """ | ||
| 31 | {"protocol_version":1,"data":[\ | ||
| 32 | {"number":3,"title":"mine","state":"open","author":"krz","assignees":["cmc"],\ | ||
| 33 | "created_at":"2026-08-23T10:00:00.000Z"},\ | ||
| 34 | {"number":4,"title":"theirs","state":"open","author":"krz","assignees":["krz"],\ | ||
| 35 | "created_at":"2026-08-23T11:00:00.000Z"}\ | ||
| 36 | ],"exit_code":0} | ||
| 37 | """ | ||
| 38 | |||
| 39 | private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 40 | |||
| 41 | private let buildJSON = """ | ||
| 42 | {"protocol_version":1,"data":[\ | ||
| 43 | {"number":9,"job":"ci","status":"success","sha":"aa00000000",\ | ||
| 44 | "ref":"refs/heads/main","created_at":"2026-08-22T10:05:00.000Z"}\ | ||
| 45 | ],"exit_code":0} | ||
| 46 | """ | ||
| 47 | |||
| 48 | @MainActor | ||
| 49 | struct DashboardViewModelTests { | ||
| 50 | |||
| 51 | @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws { | ||
| 52 | let (client, stub) = try makeClient() | ||
| 53 | stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) | ||
| 54 | // krz/gitbay: one open MR, one assigned + one unassigned issue, a build. | ||
| 55 | stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay")) | ||
| 56 | stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay")) | ||
| 57 | stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay")) | ||
| 58 | // krz/hutch: nothing open — no build call should follow. | ||
| 59 | stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=mr&argv=list&argv=krz/hutch")) | ||
| 60 | stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=issue&argv=list&argv=krz/hutch")) | ||
| 61 | let model = DashboardViewModel(client: client, username: "cmc") | ||
| 62 | |||
| 63 | await model.load() | ||
| 64 | |||
| 65 | #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"]) | ||
| 66 | // Only issues assigned to me, and never from the archived repo. | ||
| 67 | #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"]) | ||
| 68 | #expect(model.recentBuilds.map(\.id) == ["krz/gitbay#9"]) | ||
| 69 | #expect(model.scanNote == nil) | ||
| 70 | // The archived repo cost zero requests; the quiet repo cost two. | ||
| 71 | let scanned = stub.seen.map { $0.url.query() ?? "" } | ||
| 72 | #expect(!scanned.contains { $0.contains("krz/dust") }) | ||
| 73 | #expect(scanned.count { $0.contains("krz/hutch") } == 2) | ||
| 74 | } | ||
| 75 | |||
| 76 | @Test func rateLimitStopsTheScanAndSaysSo() async throws { | ||
| 77 | let (client, stub) = try makeClient() | ||
| 78 | stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) | ||
| 79 | stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay")) | ||
| 80 | stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay")) | ||
| 81 | stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay")) | ||
| 82 | stub.enqueue(.init( | ||
| 83 | status: 429, | ||
| 84 | headers: ["Retry-After": "30"], | ||
| 85 | json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#, | ||
| 86 | match: "krz/hutch" | ||
| 87 | )) | ||
| 88 | stub.enqueue(.init( | ||
| 89 | status: 429, | ||
| 90 | headers: ["Retry-After": "30"], | ||
| 91 | json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#, | ||
| 92 | match: "krz/hutch" | ||
| 93 | )) | ||
| 94 | let model = DashboardViewModel(client: client, username: "cmc") | ||
| 95 | |||
| 96 | await model.load() | ||
| 97 | |||
| 98 | // What arrived before the limit is still on screen. | ||
| 99 | #expect(model.openMRs.count == 1) | ||
| 100 | let note = try #require(model.scanNote) | ||
| 101 | #expect(note.contains("Rate limited")) | ||
| 102 | } | ||
| 103 | } | ||