summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:16:14 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:16:14 -0500
commit7a875798fea9321bad2636040d9be2c30bc0fa8f (patch)
treeb0569f6413454f423c4ddaa6b25307bc987ef81d /Hutch/Views
parent86bcd9452d3107f68aca085e03e69b02667c0dbb (diff)
downloadhutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.tar.gz
hutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.tar.bz2
hutch-7a875798fea9321bad2636040d9be2c30bc0fa8f.zip
feat: add repository acl management
Implements: https://todo.sr.ht/~ccleberg/hutch/39 Implements: https://todo.sr.ht/~ccleberg/hutch/40 Implements: https://todo.sr.ht/~ccleberg/hutch/41
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Repositories/RepositoryACLView.swift239
-rw-r--r--Hutch/Views/Repositories/RepositoryACLViewModel.swift213
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift16
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsView.swift85
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift157
5 files changed, 475 insertions, 235 deletions
diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift
new file mode 100644
index 0000000..0368b4f
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryACLView.swift
@@ -0,0 +1,239 @@
+import SwiftUI
+
+struct RepositoryACLView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ let showsDoneButton: Bool
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryACLViewModel?
+ @State private var pendingDeletion: RepositoryACLEntry?
+ @State private var showAddSheet = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading access…")
+ }
+ }
+ .navigationTitle("Access")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if showsDoneButton {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+
+ if viewModel != nil {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add User")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let service = RepositoryACLService(client: client, service: repository.service)
+ let vm = RepositoryACLViewModel(repository: repository, service: service)
+ viewModel = vm
+ await vm.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: RepositoryACLViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Group {
+ if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
+ SRHTLoadingStateView(message: "Loading access…")
+ } else if let loadError = viewModel.loadError, !viewModel.hasEntries {
+ SRHTErrorStateView(
+ title: "Couldn't Load Access",
+ message: loadError,
+ retryAction: { await viewModel.load() }
+ )
+ } else {
+ List {
+ if viewModel.visibleEntries.isEmpty {
+ ContentUnavailableView {
+ Label("No Additional Access", systemImage: "person.2.slash")
+ } description: {
+ Text("Only the repository owner currently has access.")
+ }
+ .frame(maxWidth: .infinity)
+ .listRowBackground(Color.clear)
+ } else {
+ Section {
+ ForEach(viewModel.visibleEntries) { entry in
+ RepositoryACLEntryRow(
+ entry: entry,
+ isUpdating: viewModel.isUpdating(entry),
+ isDeleting: viewModel.isDeleting(entry),
+ onSelectMode: { mode in
+ Task { await viewModel.updatePermission(for: entry, to: mode) }
+ },
+ onDelete: {
+ pendingDeletion = entry
+ }
+ )
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable {
+ await viewModel.load()
+ }
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .alert("Remove Access?", isPresented: Binding(
+ get: { pendingDeletion != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingDeletion = nil
+ }
+ }
+ )) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Access", role: .destructive) {
+ guard let entry = pendingDeletion else { return }
+ Task {
+ await viewModel.removeEntry(entry)
+ pendingDeletion = nil
+ }
+ }
+ } message: {
+ if let entry = pendingDeletion {
+ Text("\(entry.entity.canonicalName) will lose \(entry.mode.displayName.lowercased()) access to this repository.")
+ }
+ }
+ .sheet(isPresented: $showAddSheet) {
+ NavigationStack {
+ RepositoryACLAddUserView(viewModel: viewModel) {
+ showAddSheet = false
+ }
+ }
+ }
+ }
+}
+
+private struct RepositoryACLEntryRow: View {
+ let entry: RepositoryACLEntry
+ let isUpdating: Bool
+ let isDeleting: Bool
+ let onSelectMode: (AccessMode) -> Void
+ let onDelete: () -> Void
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 12) {
+ Text(entry.entity.canonicalName)
+ .font(.body.monospaced())
+ .lineLimit(2)
+ .truncationMode(.middle)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if isUpdating || isDeleting {
+ ProgressView()
+ .controlSize(.small)
+ }
+
+ Menu {
+ ForEach(AccessMode.allCases, id: \.self) { mode in
+ Button {
+ onSelectMode(mode)
+ } label: {
+ if mode == entry.mode {
+ Label(mode.displayName, systemImage: "checkmark")
+ } else {
+ Text(mode.displayName)
+ }
+ }
+ }
+ } label: {
+ Text(entry.mode.shortLabel)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(.quaternary, in: Capsule())
+ }
+ .disabled(isUpdating || isDeleting)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ onDelete()
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ .disabled(isUpdating || isDeleting)
+ }
+ }
+}
+
+private struct RepositoryACLAddUserView: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @Bindable var viewModel: RepositoryACLViewModel
+ let onAdded: () -> Void
+
+ var body: some View {
+ Form {
+ Section("User") {
+ TextField("Username or ~username", text: $viewModel.addUsername)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ if let validation = inlineValidationMessage {
+ Text(validation)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Permission") {
+ Picker("Permission", selection: $viewModel.addMode) {
+ ForEach(AccessMode.allCases, id: \.self) { mode in
+ Text(mode.shortLabel).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+ }
+ .navigationTitle("Add User")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Add") {
+ Task {
+ if await viewModel.addEntry() {
+ onAdded()
+ }
+ }
+ }
+ .disabled(!viewModel.canSubmitNewEntry)
+ }
+ }
+ }
+
+ private var inlineValidationMessage: String? {
+ let trimmed = viewModel.addUsername.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return viewModel.addValidationMessage
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryACLViewModel.swift b/Hutch/Views/Repositories/RepositoryACLViewModel.swift
new file mode 100644
index 0000000..95ca1c4
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryACLViewModel.swift
@@ -0,0 +1,213 @@
+import Foundation
+
+@Observable
+@MainActor
+final class RepositoryACLViewModel {
+ let repository: RepositorySummary
+
+ private let service: any RepositoryACLServicing
+
+ private(set) var entries: [RepositoryACLEntry] = []
+ private(set) var isLoading = false
+ private(set) var loadError: String?
+ private(set) var updatingEntryIDs: Set<Int> = []
+ private(set) var deletingEntryIDs: Set<Int> = []
+ private(set) var isCreatingEntry = false
+
+ var addUsername = ""
+ var addMode: AccessMode = .ro
+ var error: String?
+
+ init(
+ repository: RepositorySummary,
+ service: any RepositoryACLServicing
+ ) {
+ self.repository = repository
+ self.service = service
+ }
+
+ var visibleEntries: [RepositoryACLEntry] {
+ sort(entries.filter { normalizedIdentity($0.entity.canonicalName) != normalizedIdentity(repository.owner.canonicalName) })
+ }
+
+ var hasEntries: Bool {
+ !visibleEntries.isEmpty
+ }
+
+ var addValidationMessage: String? {
+ Self.validateEntityInput(
+ addUsername,
+ ownerCanonicalName: repository.owner.canonicalName,
+ existingEntities: visibleEntries.map(\.entity.canonicalName)
+ )
+ }
+
+ var canSubmitNewEntry: Bool {
+ addValidationMessage == nil && !isCreatingEntry
+ }
+
+ func load() async {
+ guard !isLoading else { return }
+
+ isLoading = true
+ defer { isLoading = false }
+
+ do {
+ entries = try await service.fetchACLs(repositoryRid: repository.rid)
+ loadError = nil
+ } catch {
+ let message = error.userFacingMessage
+ if entries.isEmpty {
+ loadError = message
+ } else {
+ self.error = message
+ }
+ }
+ }
+
+ func addEntry() async -> Bool {
+ guard let entity = validatedNewEntity() else {
+ error = addValidationMessage ?? "Enter a valid username."
+ return false
+ }
+
+ isCreatingEntry = true
+ defer { isCreatingEntry = false }
+ error = nil
+
+ do {
+ let entry = try await service.upsertACL(
+ repositoryId: repository.id,
+ entity: entity,
+ mode: addMode
+ )
+ merge(entry)
+ addUsername = ""
+ addMode = .ro
+ await refreshAfterMutation()
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
+ func updatePermission(for entry: RepositoryACLEntry, to mode: AccessMode) async {
+ guard entry.mode != mode else { return }
+
+ updatingEntryIDs.insert(entry.id)
+ defer { updatingEntryIDs.remove(entry.id) }
+ error = nil
+
+ do {
+ let updatedEntry = try await service.upsertACL(
+ repositoryId: repository.id,
+ entity: entry.entity.canonicalName,
+ mode: mode
+ )
+ merge(updatedEntry)
+ await refreshAfterMutation()
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ func removeEntry(_ entry: RepositoryACLEntry) async {
+ deletingEntryIDs.insert(entry.id)
+ defer { deletingEntryIDs.remove(entry.id) }
+ error = nil
+
+ do {
+ try await service.deleteACL(entryId: entry.id)
+ entries.removeAll { $0.id == entry.id }
+ await refreshAfterMutation()
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ func isUpdating(_ entry: RepositoryACLEntry) -> Bool {
+ updatingEntryIDs.contains(entry.id)
+ }
+
+ func isDeleting(_ entry: RepositoryACLEntry) -> Bool {
+ deletingEntryIDs.contains(entry.id)
+ }
+
+ static func canonicalEntity(from input: String) -> String? {
+ guard validateEntityInput(input, ownerCanonicalName: nil, existingEntities: []) == nil else {
+ return nil
+ }
+
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ return "~\(username)"
+ }
+
+ static func validateEntityInput(
+ _ input: String,
+ ownerCanonicalName: String?,
+ existingEntities: [String]
+ ) -> String? {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "Enter a username." }
+ guard !trimmed.contains(where: \.isWhitespace) else { return "Usernames cannot contain spaces." }
+
+ let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ guard !username.isEmpty else { return "Enter a username." }
+ guard username.first != "~", !username.contains("~") else { return "Enter a valid username." }
+ guard username.range(of: #"^[A-Za-z0-9][A-Za-z0-9._-]*$"#, options: .regularExpression) != nil else {
+ return "Enter a valid username."
+ }
+
+ if let ownerCanonicalName, normalizedIdentity(ownerCanonicalName) == normalizedIdentity(username) {
+ return "The repository owner already has access."
+ }
+
+ if existingEntities.contains(where: { normalizedIdentity($0) == normalizedIdentity(username) }) {
+ return "That user already has access."
+ }
+
+ return nil
+ }
+}
+
+private extension RepositoryACLViewModel {
+ func validatedNewEntity() -> String? {
+ guard addValidationMessage == nil else { return nil }
+ return Self.canonicalEntity(from: addUsername)
+ }
+
+ func refreshAfterMutation() async {
+ do {
+ entries = try await service.fetchACLs(repositoryRid: repository.rid)
+ loadError = nil
+ } catch {
+ self.error = "Saved, but couldn't refresh access list: \(error.userFacingMessage)"
+ }
+ }
+
+ func merge(_ entry: RepositoryACLEntry) {
+ if let index = entries.firstIndex(where: { $0.id == entry.id }) {
+ entries[index] = entry
+ } else {
+ entries.append(entry)
+ }
+ entries = sort(entries)
+ }
+
+ func sort(_ entries: [RepositoryACLEntry]) -> [RepositoryACLEntry] {
+ entries.sorted {
+ $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending
+ }
+ }
+
+ static func normalizedIdentity(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()).lowercased() : trimmed.lowercased()
+ }
+
+ func normalizedIdentity(_ value: String) -> String {
+ Self.normalizedIdentity(value)
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 39ef912..b19e5b0 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -9,6 +9,7 @@ struct RepositoryDetailView: View {
@State private var viewModel: RepositoryDetailViewModel?
@State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
+ @State private var showACLs = false
@State private var displayName: String
private var canManageRepository: Bool {
@@ -43,6 +44,12 @@ struct RepositoryDetailView: View {
if canManageRepository {
Button {
+ showACLs = true
+ } label: {
+ Image(systemName: "person.2")
+ }
+
+ Button {
showSettings = true
} label: {
Image(systemName: "gear")
@@ -64,6 +71,15 @@ struct RepositoryDetailView: View {
}
)
}
+ .sheet(isPresented: $showACLs) {
+ NavigationStack {
+ RepositoryACLView(
+ repository: repository,
+ client: appState.client,
+ showsDoneButton: true
+ )
+ }
+ }
.task {
if viewModel == nil {
viewModel = RepositoryDetailViewModel(
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
index 98d3d8a..e6beedf 100644
--- a/Hutch/Views/Repositories/RepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -11,7 +11,6 @@ struct RepositorySettingsView: View {
@State private var viewModel: RepositorySettingsViewModel?
@State private var showDeleteConfirmation = false
@State private var showRenameConfirmation = false
- @State private var pendingACLDeletion: ACLEntry?
@State private var saveResultAlert: SaveResultAlert?
var body: some View {
@@ -39,7 +38,6 @@ struct RepositorySettingsView: View {
client: client
)
viewModel = vm
- await vm.loadACLs()
}
}
}
@@ -51,7 +49,7 @@ struct RepositorySettingsView: View {
Form {
infoSection(viewModel)
renameSection(viewModel)
- accessSection(viewModel)
+ accessSection()
deleteSection(viewModel)
}
.srhtErrorBanner(error: $vm.error)
@@ -93,29 +91,6 @@ struct RepositorySettingsView: View {
} message: {
Text("This cannot be undone.")
}
- .alert("Remove Access?", isPresented: Binding(
- get: { pendingACLDeletion != nil },
- set: { isPresented in
- if !isPresented {
- pendingACLDeletion = nil
- }
- }
- )) {
- Button("Cancel", role: .cancel) {
- // Alert dismissal is implicit; no additional action required.
- }
- Button("Remove Access", role: .destructive) {
- guard let entry = pendingACLDeletion else { return }
- Task {
- await viewModel.deleteACL(entry)
- pendingACLDeletion = nil
- }
- }
- } message: {
- if let entry = pendingACLDeletion {
- Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
- }
- }
.alert(item: $saveResultAlert) { alert in
Alert(
title: Text(alert.title),
@@ -207,61 +182,15 @@ struct RepositorySettingsView: View {
// MARK: - Access Section
@ViewBuilder
- private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ private func accessSection() -> some View {
Section {
- if viewModel.isLoadingACLs {
- HStack {
- Spacer()
- ProgressView()
- Spacer()
- }
- } else if viewModel.acls.isEmpty {
- Text("No access entries yet.")
- .foregroundStyle(.secondary)
- } else {
- ForEach(viewModel.acls) { entry in
- HStack {
- Text(entry.entity.canonicalName)
- Spacer()
- Text(entry.mode)
- .font(.caption.monospaced())
- .foregroundStyle(.secondary)
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button(role: .destructive) {
- pendingACLDeletion = entry
- } label: {
- Label("Remove Access", systemImage: "trash")
- }
- }
- }
+ NavigationLink {
+ RepositoryACLView(repository: repository, client: client, showsDoneButton: false)
+ } label: {
+ Label("Manage Access", systemImage: "person.2")
}
- // Add ACL form
- HStack {
- TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
- .autocorrectionDisabled()
- .textInputAutocapitalization(.never)
-
- Picker("", selection: Bindable(viewModel).newACLMode) {
- Text("RO").tag("RO")
- Text("RW").tag("RW")
- }
- .pickerStyle(.segmented)
- .frame(width: 100)
-
- Button {
- Task { await viewModel.addACL() }
- } label: {
- if viewModel.isAddingACL {
- ProgressView()
- } else {
- Text("Add")
- }
- }
- .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- Text("Add a SourceHut user and choose read-only or read/write access.")
+ Text("Review and update repository access without leaving settings.")
.font(.caption)
.foregroundStyle(.secondary)
} header: {
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index 571ae85..30fe040 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -22,31 +22,6 @@ private struct UpdatedRepoInfo: Decodable, Sendable {
let id: Int
}
-private struct ACLResponse: Decodable, Sendable {
- let repository: ACLRepository?
-}
-
-private struct ACLRepository: Decodable, Sendable {
- let acls: ACLPage
-}
-
-private struct ACLPage: Decodable, Sendable {
- let results: [ACLEntry]
- let cursor: String?
-}
-
-private struct UpdateACLResponse: Decodable, Sendable {
- let updateACL: ACLEntry
-}
-
-private struct DeleteACLResponse: Decodable, Sendable {
- let deleteACL: DeletedACL
-}
-
-private struct DeletedACL: Decodable, Sendable {
- let id: Int
-}
-
private struct DeleteRepoResponse: Decodable, Sendable {
let deleteRepository: DeletedRepo
}
@@ -55,14 +30,6 @@ private struct DeletedRepo: Decodable, Sendable {
let id: Int
}
-// MARK: - ACL Model
-
-struct ACLEntry: Decodable, Sendable, Identifiable {
- let id: Int
- let mode: String
- let entity: Entity
-}
-
// MARK: - View Model
@Observable
@@ -87,15 +54,6 @@ final class RepositorySettingsViewModel {
var editedName: String
var isRenaming = false
- // MARK: - ACL state
-
- private(set) var acls: [ACLEntry] = []
- private(set) var isLoadingACLs = false
- var newACLEntity = ""
- var newACLMode = "RO"
- var isAddingACL = false
- var isDeletingACL = false
-
// MARK: - Delete state
var isDeleting = false
@@ -202,103 +160,6 @@ final class RepositorySettingsViewModel {
}
}
- // MARK: - ACLs
-
- private static let aclsQuery = """
- query acls($rid: ID!) {
- repository(rid: $rid) {
- acls {
- results {
- id
- mode
- entity { canonicalName }
- }
- cursor
- }
- }
- }
- """
-
- private static let updateACLMutation = """
- mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
- updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
- id mode entity { canonicalName }
- }
- }
- """
-
- private static let deleteACLMutation = """
- mutation deleteACL($id: Int!) {
- deleteACL(id: $id) { id }
- }
- """
-
- private static let userLookupQuery = """
- query userLookup($username: String!) {
- user(username: $username) {
- id
- username
- canonicalName
- }
- }
- """
-
- func loadACLs() async {
- guard !isLoadingACLs else { return }
- isLoadingACLs = true
- defer { isLoadingACLs = false }
-
- do {
- let result = try await client.execute(
- service: service,
- query: Self.aclsQuery,
- variables: ["rid": repositoryRid],
- responseType: ACLResponse.self
- )
- acls = result.repository?.acls.results ?? []
- } catch {
- self.error = error.userFacingMessage
- }
- }
-
- func addACL() async {
- let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !rawEntity.isEmpty else { return }
- let entity = Self.gitCanonicalEntity(from: rawEntity)
- isAddingACL = true
- defer { isAddingACL = false }
- error = nil
-
- do {
- let result = try await client.execute(
- service: service,
- query: Self.updateACLMutation,
- variables: [
- "repoId": repositoryId,
- "mode": newACLMode,
- "entity": entity
- ],
- responseType: UpdateACLResponse.self
- )
- // Replace existing entry or append
- if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
- acls[index] = result.updateACL
- } else {
- acls.append(result.updateACL)
- }
- newACLEntity = ""
- } catch {
- self.error = error.userFacingMessage
- }
- }
-
- static func gitCanonicalEntity(from input: String) -> String {
- let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return trimmed }
- let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
- return "~\(username)"
- }
-
static func gitHeadReference(from input: String) -> String {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
@@ -319,24 +180,6 @@ final class RepositorySettingsViewModel {
}?.name
}
- func deleteACL(_ entry: ACLEntry) async {
- isDeletingACL = true
- defer { isDeletingACL = false }
- error = nil
-
- do {
- _ = try await client.execute(
- service: service,
- query: Self.deleteACLMutation,
- variables: ["id": entry.id],
- responseType: DeleteACLResponse.self
- )
- acls.removeAll { $0.id == entry.id }
- } catch {
- self.error = error.userFacingMessage
- }
- }
-
// MARK: - Delete Repository
private static let deleteRepoMutation = """