import SwiftUI /// `admin repo list`: every repository on the instance, filtered by owner /// and visibility, with the writes an admin makes on any of them. struct AdminReposView: View { @State private var model: AdminReposViewModel @State private var deleting: AdminRepo? @State private var typed = "" /// Archive and visibility change what everyone sees, so they ask first. @State private var confirming: Pending? nonisolated struct Pending: Hashable, Sendable, Identifiable { let action: AdminRepoAction let repo: AdminRepo var id: String { repo.path + String(describing: action) } var title: String { switch action { case .archive: "Archive \(repo.path)?" case .unarchive: "Unarchive \(repo.path)?" case .makePublic: "Make \(repo.path) public?" case .makePrivate: "Make \(repo.path) private?" default: "" } } var message: String { switch action { case .archive: "It becomes read-only: pushes, issues and merge requests are refused. Audited." case .unarchive: "It takes pushes, issues and merge requests again. Audited." case .makePublic: "Anyone, signed in or not, can read it. Audited." case .makePrivate: "Only its owner and those granted access can read it. Audited." default: "" } } } init(client: GitbayClient) { _model = State(initialValue: AdminReposViewModel(client: client)) } var body: some View { List { Section { LabeledContent("Owner") { TextField("any", text: Bindable(model).owner) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) .onSubmit { Task { await model.load() } } .accessibilityIdentifier("admin-repos-owner") } Picker("Visibility", selection: Bindable(model).visibility) { Text("Any").tag(String?.none) Text("Public").tag(String?.some("public")) Text("Private").tag(String?.some("private")) } } .font(.gbSans(.subheadline)) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let notice = model.notice { Section { GBNotice(notice, .gbOK) } } ForEach(model.list.state.value ?? []) { repo in row(repo) .onAppear { if repo.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } } } } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.list.state) } .navigationTitle("Repositories") .navigationBarTitleDisplayMode(.inline) .task { if model.list.needsLoad { await model.load() } } .refreshable { await model.load() } .onChange(of: model.visibility) { Task { await model.load() } } .confirmationDialog(confirming?.title ?? "", isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), titleVisibility: .visible, presenting: confirming) { pending in Button(pending.action == .makePublic ? "Make public" : "Confirm", role: pending.action == .makePublic || pending.action == .archive ? .destructive : nil) { Task { await model.perform(pending.action, on: pending.repo) } } Button("Cancel", role: .cancel) {} } message: { pending in Text(pending.message) } .alert("Delete \(deleting?.path ?? "")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), presenting: deleting) { repo in TextField(repo.path, text: $typed) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Delete", role: .destructive) { Task { await model.perform(.delete, on: repo) } } .disabled(typed.trimmingCharacters(in: .whitespaces) != repo.path) Button("Cancel", role: .cancel) {} } message: { repo in Text("Deletes \(repo.path) with its issues, merge requests, releases and builds. Audited. Type the path to confirm.") } } private func row(_ repo: AdminRepo) -> some View { HStack { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(repo.path).font(.gbSans(.subheadline).weight(.medium)) if repo.visibility == "private" { GBChip("Private", .secondary) } if repo.isArchived { GBChip("archived", .gbWarn) } } HStack(spacing: 4) { Text(repo.bytes.formatted(.byteCount(style: .file, spellsOutZero: false))) Text("ยท") Text(repo.lastPush.map { "pushed \($0.formatted(.relative(presentation: .named)))" } ?? "never pushed") } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Spacer() Menu { Button(repo.isArchived ? "Unarchive" : "Archive") { confirming = Pending(action: repo.isArchived ? .unarchive : .archive, repo: repo) } Button(repo.visibility == "private" ? "Make public" : "Make private") { confirming = Pending(action: repo.visibility == "private" ? .makePublic : .makePrivate, repo: repo) } Button("Rebuild symbol index") { Task { await model.perform(.reindex, on: repo) } } Divider() Button("Delete", role: .destructive) { typed = "" deleting = repo } } label: { Image(systemName: "ellipsis.circle") .frame(minWidth: 44, minHeight: 44) } .disabled(model.working) .accessibilityLabel("Actions for \(repo.path)") .accessibilityIdentifier("admin-repo-menu-\(repo.path)") } } } /// `admin org limits`: an organization's repository and storage caps /// against what it holds, and setting them. struct AdminOrgLimitsView: View { @State private var model: AdminOrgLimitsViewModel @State private var org = "" @State private var editing = false init(client: GitbayClient) { _model = State(initialValue: AdminOrgLimitsViewModel(client: client)) } var body: some View { List { Section { LabeledContent("Organization") { TextField("name", text: $org) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.search) .onSubmit { Task { await model.load(org: org) } } .accessibilityIdentifier("org-limits-name") } .font(.gbSans(.subheadline)) Button("Show") { Task { await model.load(org: org) } } .disabled(org.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("org-limits-show") } if let error = model.actionError, !editing { Section { GBNotice(error, .gbWarn) } } if let l = model.limits, let name = model.loadedOrg { Section { LabeledContent("Repositories", value: "\(l.reposOwned) of \(l.repos == 0 ? "unlimited" : String(l.repos))") LabeledContent("Storage", value: "\(l.bytesOwned.formatted(.byteCount(style: .file, spellsOutZero: false))) of \(l.bytes == 0 ? "unlimited" : l.bytes.formatted(.byteCount(style: .file)))") Button("Edit limits") { editing = true } .accessibilityIdentifier("org-limits-edit") } header: { Text(name) } footer: { Text(l.override ? "Set on this organization." : "The instance's defaults.") } .font(.gbSans(.subheadline)) } } .navigationTitle("Organization Limits") .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $editing) { LimitsSheet(heading: "Limits for \(model.loadedOrg ?? "")", hasOrgs: false, working: model.working, error: model.actionError) { repos, megabytes, _ in if await model.set(repos: repos, megabytes: megabytes) { editing = false } } } } }