Commit 0e79b13ed6

0e79b13ed6b1865df38f3752eece7d424eec9768

parent: 7584e76f6d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 23:37 UTC

Repository actions in the repo menu

Layout: unified · split

gitbay/Views/Repos/RepoView.swift +70 −3
@@ -3,25 +3,37 @@ import SwiftUI
3struct RepoView: View { 3struct 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)