gitbay/Views/Admin/AdminReposViews.swift
203 lines · 9187 bytes
1import SwiftUI
2
3/// `admin repo list`: every repository on the instance, filtered by owner
4/// and visibility, with the writes an admin makes on any of them.
5struct AdminReposView: View {
6
7 @State private var model: AdminReposViewModel
8 @State private var deleting: AdminRepo?
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 }
38
39 init(client: GitbayClient) {
40 _model = State(initialValue: AdminReposViewModel(client: client))
41 }
42
43 var body: some View {
44 List {
45 Section {
46 LabeledContent("Owner") {
47 TextField("any", text: Bindable(model).owner)
48 .multilineTextAlignment(.trailing)
49 .autocorrectionDisabled()
50 .textInputAutocapitalization(.never)
51 .onSubmit { Task { await model.load() } }
52 .accessibilityIdentifier("admin-repos-owner")
53 }
54 Picker("Visibility", selection: Bindable(model).visibility) {
55 Text("Any").tag(String?.none)
56 Text("Public").tag(String?.some("public"))
57 Text("Private").tag(String?.some("private"))
58 }
59 }
60 .font(.gbSans(.subheadline))
61 if let error = model.actionError {
62 Section { GBNotice(error, .gbWarn) }
63 }
64 if let notice = model.notice {
65 Section { GBNotice(notice, .gbOK) }
66 }
67 ForEach(model.list.state.value ?? []) { repo in
68 row(repo)
69 .onAppear {
70 if repo.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } }
71 }
72 }
73 PageFooter(list: model.list)
74 }
75 .overlay { LoadStateOverlay(state: model.list.state) }
76 .navigationTitle("Repositories")
77 .navigationBarTitleDisplayMode(.inline)
78 .task { if model.list.needsLoad { await model.load() } }
79 .refreshable { 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 }
92 .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
93 presenting: deleting) { repo in
94 TextField(repo.path, text: $typed)
95 .autocorrectionDisabled()
96 .textInputAutocapitalization(.never)
97 Button("Delete", role: .destructive) { Task { await model.perform(.delete, on: repo) } }
98 .disabled(typed.trimmingCharacters(in: .whitespaces) != repo.path)
99 Button("Cancel", role: .cancel) {}
100 } message: { repo in
101 Text("Deletes \(repo.path) with its issues, merge requests, releases and builds. Audited. Type the path to confirm.")
102 }
103 }
104
105 private func row(_ repo: AdminRepo) -> some View {
106 HStack {
107 VStack(alignment: .leading, spacing: 3) {
108 HStack(spacing: 6) {
109 Text(repo.path).font(.gbSans(.subheadline).weight(.medium))
110 if repo.visibility == "private" { GBChip("Private", .secondary) }
111 if repo.isArchived { GBChip("archived", .gbWarn) }
112 }
113 HStack(spacing: 4) {
114 Text(repo.bytes.formatted(.byteCount(style: .file, spellsOutZero: false)))
115 Text("·")
116 Text(repo.lastPush.map { "pushed \($0.formatted(.relative(presentation: .named)))" } ?? "never pushed")
117 }
118 .font(.gbSans(.caption))
119 .foregroundStyle(.secondary)
120 }
121 Spacer()
122 Menu {
123 Button(repo.isArchived ? "Unarchive" : "Archive") {
124 confirming = Pending(action: repo.isArchived ? .unarchive : .archive, repo: repo)
125 }
126 Button(repo.visibility == "private" ? "Make public" : "Make private") {
127 confirming = Pending(action: repo.visibility == "private" ? .makePublic : .makePrivate, repo: repo)
128 }
129 Button("Rebuild symbol index") {
130 Task { await model.perform(.reindex, on: repo) }
131 }
132 Divider()
133 Button("Delete", role: .destructive) {
134 typed = ""
135 deleting = repo
136 }
137 } label: {
138 Image(systemName: "ellipsis.circle")
139 .frame(minWidth: 44, minHeight: 44)
140 }
141 .disabled(model.working)
142 .accessibilityLabel("Actions for \(repo.path)")
143 .accessibilityIdentifier("admin-repo-menu-\(repo.path)")
144 }
145 }
146}
147
148/// `admin org limits`: an organization's repository and storage caps
149/// against what it holds, and setting them.
150struct AdminOrgLimitsView: View {
151
152 @State private var model: AdminOrgLimitsViewModel
153 @State private var org = ""
154 @State private var editing = false
155
156 init(client: GitbayClient) {
157 _model = State(initialValue: AdminOrgLimitsViewModel(client: client))
158 }
159
160 var body: some View {
161 List {
162 Section {
163 LabeledContent("Organization") {
164 TextField("name", text: $org)
165 .multilineTextAlignment(.trailing)
166 .autocorrectionDisabled()
167 .textInputAutocapitalization(.never)
168 .submitLabel(.search)
169 .onSubmit { Task { await model.load(org: org) } }
170 .accessibilityIdentifier("org-limits-name")
171 }
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")
176 }
177 if let error = model.actionError, !editing {
178 Section { GBNotice(error, .gbWarn) }
179 }
180 if let l = model.limits, let name = model.loadedOrg {
181 Section {
182 LabeledContent("Repositories", value: "\(l.reposOwned) of \(l.repos == 0 ? "unlimited" : String(l.repos))")
183 LabeledContent("Storage", value: "\(l.bytesOwned.formatted(.byteCount(style: .file, spellsOutZero: false))) of \(l.bytes == 0 ? "unlimited" : l.bytes.formatted(.byteCount(style: .file)))")
184 Button("Edit limits") { editing = true }
185 .accessibilityIdentifier("org-limits-edit")
186 } header: {
187 Text(name)
188 } footer: {
189 Text(l.override ? "Set on this organization." : "The instance's defaults.")
190 }
191 .font(.gbSans(.subheadline))
192 }
193 }
194 .navigationTitle("Organization Limits")
195 .navigationBarTitleDisplayMode(.inline)
196 .sheet(isPresented: $editing) {
197 LimitsSheet(heading: "Limits for \(model.loadedOrg ?? "")", hasOrgs: false, working: model.working,
198 error: model.actionError) { repos, megabytes, _ in
199 if await model.set(repos: repos, megabytes: megabytes) { editing = false }
200 }
201 }
202 }
203}