Commit d1741e8b69

d1741e8b698169b769b43d3dd4268ceaa85a06b7

parent: 553ada2a6a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 08:50 UTC

Admin repositories: ask before archive and visibility; the list keeps its place

Archive, unarchive and the visibility switch ask first, as the
repository screen does for archive, and say what each changes. Every
change but a reindex tells screens showing the repository to reload,
and a delete whose retry finds it gone is reported done. The list
refreshes in place after an action rather than blanking, and
organization limits have a Show button.

Ref #38

Layout: unified · split

gitbay/Admin/AdminRepos.swift +14 −4
@@ -75,15 +75,25 @@ final class AdminReposViewModel {
7575 do {
7676 try await client.run(action.argv(repo.path))
7777 if action == .reindex { notice = "A rebuild of \(repo.path)'s symbol index is queued." }
78 if action == .delete {
79 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repo.path)
80 }
78 changed(repo, action)
79 } catch GitbayError.notFound where action == .delete {
80 // A failure is retried once; a delete that went through and
81 // then failed answers the retry with not found.
82 changed(repo, action)
8183 } catch let error as GitbayError {
8284 actionError = error.userFacingMessage
8385 } catch {
8486 actionError = GitbayError.transport(error).userFacingMessage
8587 }
86 await load()
88 list.argv = argv
89 await list.refresh()
90 }
91
92 /// Screens showing the repository elsewhere reload; a rebuilt index
93 /// changes nothing they draw.
94 private func changed(_ repo: AdminRepo, _ action: AdminRepoAction) {
95 guard action != .reindex else { return }
96 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repo.path)
8797 }
8898}
8999
gitbay/Repos/PagedListModel.swift +7
@@ -56,6 +56,13 @@ final class PagedListModel<Element: Decodable & Sendable> {
5656 await fetch(cursor: nil, appendingTo: [])
5757 }
5858
59 /// The first page again, keeping what is on screen until it arrives:
60 /// after a write on a row, the list should not blank.
61 func refresh() async {
62 next = nil
63 await fetch(cursor: nil, appendingTo: [])
64 }
65
5966 /// Fetch the next page. Safe to call repeatedly from row-appear
6067 /// triggers; it no-ops while a fetch is in flight or at the end.
6168 func loadMore() async {
gitbay/Views/Admin/AdminReposViews.swift +45 −2
@@ -7,6 +7,34 @@ struct AdminReposView: View {
77 @State private var model: AdminReposViewModel
88 @State private var deleting: AdminRepo?
99 @State private var typed = ""
10 /// Archive and visibility change what everyone sees, so they ask first.
11 @State private var confirming: Pending?
12
13 nonisolated struct Pending: Hashable, Sendable, Identifiable {
14 let action: AdminRepoAction
15 let repo: AdminRepo
16 var id: String { repo.path + String(describing: action) }
17
18 var title: String {
19 switch action {
20 case .archive: "Archive \(repo.path)?"
21 case .unarchive: "Unarchive \(repo.path)?"
22 case .makePublic: "Make \(repo.path) public?"
23 case .makePrivate: "Make \(repo.path) private?"
24 default: ""
25 }
26 }
27
28 var message: String {
29 switch action {
30 case .archive: "It becomes read-only: pushes, issues and merge requests are refused. Audited."
31 case .unarchive: "It takes pushes, issues and merge requests again. Audited."
32 case .makePublic: "Anyone, signed in or not, can read it. Audited."
33 case .makePrivate: "Only its owner and those granted access can read it. Audited."
34 default: ""
35 }
36 }
37 }
1038
1139 init(client: GitbayClient) {
1240 _model = State(initialValue: AdminReposViewModel(client: client))
@@ -50,6 +78,17 @@ struct AdminReposView: View {
5078 .task { if model.list.needsLoad { await model.load() } }
5179 .refreshable { await model.load() }
5280 .onChange(of: model.visibility) { Task { await model.load() } }
81 .confirmationDialog(confirming?.title ?? "",
82 isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
83 titleVisibility: .visible, presenting: confirming) { pending in
84 Button(pending.action == .makePublic ? "Make public" : "Confirm",
85 role: pending.action == .makePublic || pending.action == .archive ? .destructive : nil) {
86 Task { await model.perform(pending.action, on: pending.repo) }
87 }
88 Button("Cancel", role: .cancel) {}
89 } message: { pending in
90 Text(pending.message)
91 }
5392 .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
5493 presenting: deleting) { repo in
5594 TextField(repo.path, text: $typed)
@@ -82,10 +121,10 @@ struct AdminReposView: View {
82121 Spacer()
83122 Menu {
84123 Button(repo.isArchived ? "Unarchive" : "Archive") {
85 Task { await model.perform(repo.isArchived ? .unarchive : .archive, on: repo) }
124 confirming = Pending(action: repo.isArchived ? .unarchive : .archive, repo: repo)
86125 }
87126 Button(repo.visibility == "private" ? "Make public" : "Make private") {
88 Task { await model.perform(repo.visibility == "private" ? .makePublic : .makePrivate, on: repo) }
127 confirming = Pending(action: repo.visibility == "private" ? .makePublic : .makePrivate, repo: repo)
89128 }
90129 Button("Rebuild symbol index") {
91130 Task { await model.perform(.reindex, on: repo) }
@@ -126,10 +165,14 @@ struct AdminOrgLimitsView: View {
126165 .multilineTextAlignment(.trailing)
127166 .autocorrectionDisabled()
128167 .textInputAutocapitalization(.never)
168 .submitLabel(.search)
129169 .onSubmit { Task { await model.load(org: org) } }
130170 .accessibilityIdentifier("org-limits-name")
131171 }
132172 .font(.gbSans(.subheadline))
173 Button("Show") { Task { await model.load(org: org) } }
174 .disabled(org.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
175 .accessibilityIdentifier("org-limits-show")
133176 }
134177 if let error = model.actionError, !editing {
135178 Section { GBNotice(error, .gbWarn) }
gitbayTests/AdminRepoTests.swift +24
@@ -72,6 +72,30 @@ struct AdminRepoTests {
7272 }
7373}
7474
75@MainActor
76struct AdminRepoDeleteTests {
77
78 @Test func aDeleteWhoseRetryFindsNothingIsDoneAndAnnounced() async throws {
79 let (client, stub) = try makeClient()
80 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
81 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd"))
82 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository sim/app","exit_code":3}"#, match: "cmd"))
83 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list"))
84 let model = AdminReposViewModel(client: client)
85 await model.load()
86 let repo = try #require(model.list.state.value?.first)
87 let announced = NotificationCenter.default.notifications(named: .gitbayRepositoryChanged)
88
89 async let first = announced.first { _ in true }
90 await model.perform(.delete, on: repo)
91
92 #expect(model.actionError == nil)
93 #expect((await first)?.object as? String == "sim/app")
94 // The list stayed on screen through the reload.
95 #expect(model.list.state.value != nil)
96 }
97}
98
7599@MainActor
76100struct AdminOrgLimitsTests {
77101