gitbay/Views/Admin/AdminReposViews.swift

v1.13.0
gitbay-ios/gitbay/Views/Admin/AdminReposViews.swift history · blame · raw

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}