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 {
75 do { 75 do {
76 try await client.run(action.argv(repo.path)) 76 try await client.run(action.argv(repo.path))
77 if action == .reindex { notice = "A rebuild of \(repo.path)'s symbol index is queued." } 77 if action == .reindex { notice = "A rebuild of \(repo.path)'s symbol index is queued." }
78 if action == .delete { 78 changed(repo, action)
79 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repo.path) 79 } catch GitbayError.notFound where action == .delete {
80 } 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)
81 } catch let error as GitbayError { 83 } catch let error as GitbayError {
82 actionError = error.userFacingMessage 84 actionError = error.userFacingMessage
83 } catch { 85 } catch {
84 actionError = GitbayError.transport(error).userFacingMessage 86 actionError = GitbayError.transport(error).userFacingMessage
85 } 87 }
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)
87 } 97 }
88} 98}
89 99
gitbay/Repos/PagedListModel.swift +7
@@ -56,6 +56,13 @@ final class PagedListModel<Element: Decodable & Sendable> {
56 await fetch(cursor: nil, appendingTo: []) 56 await fetch(cursor: nil, appendingTo: [])
57 } 57 }
58 58
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
59 /// Fetch the next page. Safe to call repeatedly from row-appear 66 /// Fetch the next page. Safe to call repeatedly from row-appear
60 /// triggers; it no-ops while a fetch is in flight or at the end. 67 /// triggers; it no-ops while a fetch is in flight or at the end.
61 func loadMore() async { 68 func loadMore() async {
gitbay/Views/Admin/AdminReposViews.swift +45 −2
@@ -7,6 +7,34 @@ struct AdminReposView: View {
7 @State private var model: AdminReposViewModel 7 @State private var model: AdminReposViewModel
8 @State private var deleting: AdminRepo? 8 @State private var deleting: AdminRepo?
9 @State private var typed = "" 9 @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 }
10 38
11 init(client: GitbayClient) { 39 init(client: GitbayClient) {
12 _model = State(initialValue: AdminReposViewModel(client: client)) 40 _model = State(initialValue: AdminReposViewModel(client: client))
@@ -50,6 +78,17 @@ struct AdminReposView: View {
50 .task { if model.list.needsLoad { await model.load() } } 78 .task { if model.list.needsLoad { await model.load() } }
51 .refreshable { await model.load() } 79 .refreshable { await model.load() }
52 .onChange(of: model.visibility) { Task { await model.load() } } 80 .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 }
53 .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), 92 .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
54 presenting: deleting) { repo in 93 presenting: deleting) { repo in
55 TextField(repo.path, text: $typed) 94 TextField(repo.path, text: $typed)
@@ -82,10 +121,10 @@ struct AdminReposView: View {
82 Spacer() 121 Spacer()
83 Menu { 122 Menu {
84 Button(repo.isArchived ? "Unarchive" : "Archive") { 123 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)
86 } 125 }
87 Button(repo.visibility == "private" ? "Make public" : "Make private") { 126 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)
89 } 128 }
90 Button("Rebuild symbol index") { 129 Button("Rebuild symbol index") {
91 Task { await model.perform(.reindex, on: repo) } 130 Task { await model.perform(.reindex, on: repo) }
@@ -126,10 +165,14 @@ struct AdminOrgLimitsView: View {
126 .multilineTextAlignment(.trailing) 165 .multilineTextAlignment(.trailing)
127 .autocorrectionDisabled() 166 .autocorrectionDisabled()
128 .textInputAutocapitalization(.never) 167 .textInputAutocapitalization(.never)
168 .submitLabel(.search)
129 .onSubmit { Task { await model.load(org: org) } } 169 .onSubmit { Task { await model.load(org: org) } }
130 .accessibilityIdentifier("org-limits-name") 170 .accessibilityIdentifier("org-limits-name")
131 } 171 }
132 .font(.gbSans(.subheadline)) 172 .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")
133 } 176 }
134 if let error = model.actionError, !editing { 177 if let error = model.actionError, !editing {
135 Section { GBNotice(error, .gbWarn) } 178 Section { GBNotice(error, .gbWarn) }
gitbayTests/AdminRepoTests.swift +24
@@ -72,6 +72,30 @@ struct AdminRepoTests {
72 } 72 }
73} 73}
74 74
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
75@MainActor 99@MainActor
76struct AdminOrgLimitsTests { 100struct AdminOrgLimitsTests {
77 101