Commit 0e79b13ed6
Verified · cmc
Layout: unified · split
gitbay/Views/Repos/RepoView.swift +70 −3
| @@ -3,25 +3,37 @@ import SwiftUI | ||
| 3 | 3 | struct RepoView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: RepoDetailViewModel |
| 6 | @State private var actionsModel: RepoActionsViewModel | |
| 7 | private let client: GitbayClient | |
| 6 | 8 | private let path: String |
| 7 | 9 | private let host: String |
| 8 | 10 | |
| 9 | 11 | init(client: GitbayClient, path: String) { |
| 12 | self.client = client | |
| 10 | 13 | self.path = path |
| 11 | 14 | self.host = client.instance.baseURL.host() ?? "" |
| 12 | 15 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) |
| 16 | _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path)) | |
| 13 | 17 | } |
| 14 | 18 | |
| 15 | 19 | @State private var confirmingArchive = false |
| 20 | @State private var confirmingFork = false | |
| 21 | @State private var forkName = "" | |
| 22 | @State private var forkDestination: RepoRoute? | |
| 16 | 23 | |
| 17 | 24 | var body: some View { |
| 18 | 25 | List { |
| 19 | 26 | if let detail = model.state.value { |
| 20 | if let error = model.actionError { | |
| 27 | if let error = model.actionError ?? actionsModel.actionError { | |
| 21 | 28 | Section { |
| 22 | 29 | GBNotice(error, .gbWarn) |
| 23 | 30 | } |
| 24 | 31 | } |
| 32 | if let notice = actionsModel.notice { | |
| 33 | Section { | |
| 34 | GBNotice(notice, .gbOK) | |
| 35 | } | |
| 36 | } | |
| 25 | 37 | header(detail) |
| 26 | 38 | |
| 27 | 39 | Section { |
| @@ -84,7 +96,26 @@ struct RepoView: View { | ||
| 84 | 96 | .navigationBarTitleDisplayMode(.inline) |
| 85 | 97 | .toolbar { toolbar } |
| 86 | 98 | .task { await model.load() } |
| 99 | .task { await actionsModel.loadBookmarkState() } | |
| 87 | 100 | .refreshable { await model.load() } |
| 101 | .navigationDestination(item: $forkDestination) { route in | |
| 102 | if case .repo(let forkedPath) = route { | |
| 103 | RepoView(client: client, path: forkedPath) | |
| 104 | } | |
| 105 | } | |
| 106 | .alert("Fork \(path)", isPresented: $confirmingFork) { | |
| 107 | TextField("New name (optional)", text: $forkName) | |
| 108 | .autocorrectionDisabled() | |
| 109 | .textInputAutocapitalization(.never) | |
| 110 | Button("Fork") { | |
| 111 | Task { | |
| 112 | if let result = await actionsModel.fork(named: forkName) { | |
| 113 | forkDestination = .repo(result.path) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | Button("Cancel", role: .cancel) {} | |
| 118 | } | |
| 88 | 119 | .confirmationDialog( |
| 89 | 120 | model.state.value?.isArchived == true |
| 90 | 121 | ? "Unarchive \(path)?" |
| @@ -114,6 +145,42 @@ struct RepoView: View { | ||
| 114 | 145 | systemImage: pinned ? "pin.slash" : "pin") |
| 115 | 146 | } |
| 116 | 147 | } |
| 148 | if let bookmarked = actionsModel.isBookmarked { | |
| 149 | Button { | |
| 150 | Task { await actionsModel.setBookmarked(!bookmarked) } | |
| 151 | } label: { | |
| 152 | Label(bookmarked ? "Unbookmark" : "Bookmark", | |
| 153 | systemImage: bookmarked ? "bookmark.slash" : "bookmark") | |
| 154 | } | |
| 155 | } else { | |
| 156 | Label { | |
| 157 | Text("Checking Bookmark…") | |
| 158 | } icon: { | |
| 159 | ProgressView() | |
| 160 | } | |
| 161 | } | |
| 162 | Divider() | |
| 163 | Section { | |
| 164 | Button { | |
| 165 | Task { await actionsModel.watch() } | |
| 166 | } label: { | |
| 167 | Label("Watch this repository", systemImage: "eye") | |
| 168 | } | |
| 169 | Button { | |
| 170 | Task { await actionsModel.unwatch() } | |
| 171 | } label: { | |
| 172 | Label("Mute this repository", systemImage: "bell.slash") | |
| 173 | } | |
| 174 | // Nothing reports which of these is current (krz/gitbay#178). | |
| 175 | Text("The current setting isn't shown.") | |
| 176 | } | |
| 177 | Divider() | |
| 178 | Button { | |
| 179 | forkName = "" | |
| 180 | confirmingFork = true | |
| 181 | } label: { | |
| 182 | Label("Fork", systemImage: "tuningfork") | |
| 183 | } | |
| 117 | 184 | Divider() |
| 118 | 185 | Button(role: detail.isArchived ? nil : .destructive) { |
| 119 | 186 | confirmingArchive = true |
| @@ -122,13 +189,13 @@ struct RepoView: View { | ||
| 122 | 189 | systemImage: "archivebox") |
| 123 | 190 | } |
| 124 | 191 | } label: { |
| 125 | if model.working { | |
| 192 | if model.working || actionsModel.working { | |
| 126 | 193 | ProgressView() |
| 127 | 194 | } else { |
| 128 | 195 | Image(systemName: "ellipsis.circle") |
| 129 | 196 | } |
| 130 | 197 | } |
| 131 | .disabled(model.working) | |
| 198 | .disabled(model.working || actionsModel.working) | |
| 132 | 199 | .accessibilityIdentifier("repo-actions-menu") |
| 133 | 200 | } |
| 134 | 201 | } |
gitbayTests/RepoActionTests.swift +9 −4
| @@ -53,15 +53,20 @@ struct RepoActionsTests { | ||
| 53 | 53 | #expect(model.isBookmarked == false) |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | /// "We don't know" is not "not bookmarked". A failed load must leave | |
| 57 | /// the state unknown rather than claiming the repository is not | |
| 58 | /// bookmarked, which would draw the wrong control. | |
| 56 | /// "We don't know" is not "not bookmarked". A failed load must reset | |
| 57 | /// the state to unknown rather than leaving a stale prior value in | |
| 58 | /// place — this proves the reset by establishing a known `true` | |
| 59 | /// first, then failing a second load. | |
| 59 | 60 | @Test func aFailedListingLeavesTheStateUnknown() async throws { |
| 60 | 61 | let (client, stub) = try makeClient() |
| 62 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 63 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 64 | await model.loadBookmarkState() | |
| 65 | #expect(model.isBookmarked == true) | |
| 66 | ||
| 61 | 67 | stub.enqueue(.init(status: 200, json: """ |
| 62 | 68 | {"protocol_version":1,"error":"denied","exit_code":4} |
| 63 | 69 | """)) |
| 64 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 65 | 70 | await model.loadBookmarkState() |
| 66 | 71 | |
| 67 | 72 | #expect(model.isBookmarked == nil) |