Commit 0e79b13ed6
Verified · cmc
Layout: unified · split
gitbay/Views/Repos/RepoView.swift +70 −3
| @@ -3,25 +3,37 @@ import SwiftUI | |||
| 3 | struct RepoView: View { | 3 | struct RepoView: View { |
| 4 | 4 | ||
| 5 | @State private var model: RepoDetailViewModel | 5 | @State private var model: RepoDetailViewModel |
| 6 | @State private var actionsModel: RepoActionsViewModel | ||
| 7 | private let client: GitbayClient | ||
| 6 | private let path: String | 8 | private let path: String |
| 7 | private let host: String | 9 | private let host: String |
| 8 | 10 | ||
| 9 | init(client: GitbayClient, path: String) { | 11 | init(client: GitbayClient, path: String) { |
| 12 | self.client = client | ||
| 10 | self.path = path | 13 | self.path = path |
| 11 | self.host = client.instance.baseURL.host() ?? "" | 14 | self.host = client.instance.baseURL.host() ?? "" |
| 12 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) | 15 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) |
| 16 | _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path)) | ||
| 13 | } | 17 | } |
| 14 | 18 | ||
| 15 | @State private var confirmingArchive = false | 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 | var body: some View { | 24 | var body: some View { |
| 18 | List { | 25 | List { |
| 19 | if let detail = model.state.value { | 26 | if let detail = model.state.value { |
| 20 | if let error = model.actionError { | 27 | if let error = model.actionError ?? actionsModel.actionError { |
| 21 | Section { | 28 | Section { |
| 22 | GBNotice(error, .gbWarn) | 29 | GBNotice(error, .gbWarn) |
| 23 | } | 30 | } |
| 24 | } | 31 | } |
| 32 | if let notice = actionsModel.notice { | ||
| 33 | Section { | ||
| 34 | GBNotice(notice, .gbOK) | ||
| 35 | } | ||
| 36 | } | ||
| 25 | header(detail) | 37 | header(detail) |
| 26 | 38 | ||
| 27 | Section { | 39 | Section { |
| @@ -84,7 +96,26 @@ struct RepoView: View { | |||
| 84 | .navigationBarTitleDisplayMode(.inline) | 96 | .navigationBarTitleDisplayMode(.inline) |
| 85 | .toolbar { toolbar } | 97 | .toolbar { toolbar } |
| 86 | .task { await model.load() } | 98 | .task { await model.load() } |
| 99 | .task { await actionsModel.loadBookmarkState() } | ||
| 87 | .refreshable { await model.load() } | 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 | .confirmationDialog( | 119 | .confirmationDialog( |
| 89 | model.state.value?.isArchived == true | 120 | model.state.value?.isArchived == true |
| 90 | ? "Unarchive \(path)?" | 121 | ? "Unarchive \(path)?" |
| @@ -114,6 +145,42 @@ struct RepoView: View { | |||
| 114 | systemImage: pinned ? "pin.slash" : "pin") | 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 | Divider() | 184 | Divider() |
| 118 | Button(role: detail.isArchived ? nil : .destructive) { | 185 | Button(role: detail.isArchived ? nil : .destructive) { |
| 119 | confirmingArchive = true | 186 | confirmingArchive = true |
| @@ -122,13 +189,13 @@ struct RepoView: View { | |||
| 122 | systemImage: "archivebox") | 189 | systemImage: "archivebox") |
| 123 | } | 190 | } |
| 124 | } label: { | 191 | } label: { |
| 125 | if model.working { | 192 | if model.working || actionsModel.working { |
| 126 | ProgressView() | 193 | ProgressView() |
| 127 | } else { | 194 | } else { |
| 128 | Image(systemName: "ellipsis.circle") | 195 | Image(systemName: "ellipsis.circle") |
| 129 | } | 196 | } |
| 130 | } | 197 | } |
| 131 | .disabled(model.working) | 198 | .disabled(model.working || actionsModel.working) |
| 132 | .accessibilityIdentifier("repo-actions-menu") | 199 | .accessibilityIdentifier("repo-actions-menu") |
| 133 | } | 200 | } |
| 134 | } | 201 | } |
gitbayTests/RepoActionTests.swift +9 −4
| @@ -53,15 +53,20 @@ struct RepoActionsTests { | |||
| 53 | #expect(model.isBookmarked == false) | 53 | #expect(model.isBookmarked == false) |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | /// "We don't know" is not "not bookmarked". A failed load must leave | 56 | /// "We don't know" is not "not bookmarked". A failed load must reset |
| 57 | /// the state unknown rather than claiming the repository is not | 57 | /// the state to unknown rather than leaving a stale prior value in |
| 58 | /// bookmarked, which would draw the wrong control. | 58 | /// place — this proves the reset by establishing a known `true` |
| 59 | /// first, then failing a second load. | ||
| 59 | @Test func aFailedListingLeavesTheStateUnknown() async throws { | 60 | @Test func aFailedListingLeavesTheStateUnknown() async throws { |
| 60 | let (client, stub) = try makeClient() | 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 | stub.enqueue(.init(status: 200, json: """ | 67 | stub.enqueue(.init(status: 200, json: """ |
| 62 | {"protocol_version":1,"error":"denied","exit_code":4} | 68 | {"protocol_version":1,"error":"denied","exit_code":4} |
| 63 | """)) | 69 | """)) |
| 64 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 65 | await model.loadBookmarkState() | 70 | await model.loadBookmarkState() |
| 66 | 71 | ||
| 67 | #expect(model.isBookmarked == nil) | 72 | #expect(model.isBookmarked == nil) |