krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.5.0: Hutch/Views/Repositories/RepositoryACLViewModel.swift · raw

  1import Foundation
  2
  3@Observable
  4@MainActor
  5final class RepositoryACLViewModel {
  6    let repository: RepositorySummary
  7
  8    private let service: any RepositoryACLServicing
  9
 10    private(set) var entries: [RepositoryACLEntry] = []
 11    private(set) var isLoading = false
 12    private(set) var loadError: String?
 13    private(set) var updatingEntryIDs: Set<Int> = []
 14    private(set) var deletingEntryIDs: Set<Int> = []
 15    private(set) var isCreatingEntry = false
 16
 17    var addUsername = ""
 18    var addMode: AccessMode = .ro
 19    var error: String?
 20
 21    init(
 22        repository: RepositorySummary,
 23        service: any RepositoryACLServicing
 24    ) {
 25        self.repository = repository
 26        self.service = service
 27    }
 28
 29    var visibleEntries: [RepositoryACLEntry] {
 30        sort(entries.filter { normalizedIdentity($0.entity.canonicalName) != normalizedIdentity(repository.owner.canonicalName) })
 31    }
 32
 33    var hasEntries: Bool {
 34        !visibleEntries.isEmpty
 35    }
 36
 37    var addValidationMessage: String? {
 38        Self.validateEntityInput(
 39            addUsername,
 40            ownerCanonicalName: repository.owner.canonicalName,
 41            existingEntities: visibleEntries.map(\.entity.canonicalName)
 42        )
 43    }
 44
 45    var canSubmitNewEntry: Bool {
 46        addValidationMessage == nil && !isCreatingEntry
 47    }
 48
 49    func load() async {
 50        guard !isLoading else { return }
 51
 52        isLoading = true
 53        defer { isLoading = false }
 54
 55        do {
 56            entries = try await service.fetchACLs(repositoryRid: repository.rid)
 57            loadError = nil
 58        } catch {
 59            let message = error.userFacingMessage
 60            if entries.isEmpty {
 61                loadError = message
 62            } else {
 63                self.error = message
 64            }
 65        }
 66    }
 67
 68    func addEntry() async -> Bool {
 69        guard let entity = validatedNewEntity() else {
 70            error = addValidationMessage ?? "Enter a valid username."
 71            return false
 72        }
 73
 74        isCreatingEntry = true
 75        defer { isCreatingEntry = false }
 76        error = nil
 77
 78        do {
 79            let entry = try await service.upsertACL(
 80                repositoryId: repository.id,
 81                entity: entity,
 82                mode: addMode
 83            )
 84            merge(entry)
 85            addUsername = ""
 86            addMode = .ro
 87            await refreshAfterMutation()
 88            return true
 89        } catch {
 90            self.error = error.userFacingMessage
 91            return false
 92        }
 93    }
 94
 95    func updatePermission(for entry: RepositoryACLEntry, to mode: AccessMode) async {
 96        guard entry.mode != mode else { return }
 97
 98        updatingEntryIDs.insert(entry.id)
 99        defer { updatingEntryIDs.remove(entry.id) }
100        error = nil
101
102        do {
103            let updatedEntry = try await service.upsertACL(
104                repositoryId: repository.id,
105                entity: entry.entity.canonicalName,
106                mode: mode
107            )
108            merge(updatedEntry)
109            await refreshAfterMutation()
110        } catch {
111            self.error = error.userFacingMessage
112        }
113    }
114
115    func removeEntry(_ entry: RepositoryACLEntry) async {
116        deletingEntryIDs.insert(entry.id)
117        defer { deletingEntryIDs.remove(entry.id) }
118        error = nil
119
120        do {
121            try await service.deleteACL(entryId: entry.id)
122            entries.removeAll { $0.id == entry.id }
123            await refreshAfterMutation()
124        } catch {
125            self.error = error.userFacingMessage
126        }
127    }
128
129    func isUpdating(_ entry: RepositoryACLEntry) -> Bool {
130        updatingEntryIDs.contains(entry.id)
131    }
132
133    func isDeleting(_ entry: RepositoryACLEntry) -> Bool {
134        deletingEntryIDs.contains(entry.id)
135    }
136
137    static func canonicalEntity(from input: String) -> String? {
138        guard validateEntityInput(input, ownerCanonicalName: nil, existingEntities: []) == nil else {
139            return nil
140        }
141
142        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
143        let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
144        return "~\(username)"
145    }
146
147    static func validateEntityInput(
148        _ input: String,
149        ownerCanonicalName: String?,
150        existingEntities: [String]
151    ) -> String? {
152        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
153        guard !trimmed.isEmpty else { return "Enter a username." }
154        guard !trimmed.contains(where: \.isWhitespace) else { return "Usernames cannot contain spaces." }
155
156        let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
157        guard !username.isEmpty else { return "Enter a username." }
158        guard username.first != "~", !username.contains("~") else { return "Enter a valid username." }
159        guard username.range(of: #"^[A-Za-z0-9][A-Za-z0-9._-]*$"#, options: .regularExpression) != nil else {
160            return "Enter a valid username."
161        }
162
163        if let ownerCanonicalName, normalizedIdentity(ownerCanonicalName) == normalizedIdentity(username) {
164            return "The repository owner already has access."
165        }
166
167        if existingEntities.contains(where: { normalizedIdentity($0) == normalizedIdentity(username) }) {
168            return "That user already has access."
169        }
170
171        return nil
172    }
173}
174
175private extension RepositoryACLViewModel {
176    func validatedNewEntity() -> String? {
177        guard addValidationMessage == nil else { return nil }
178        return Self.canonicalEntity(from: addUsername)
179    }
180
181    func refreshAfterMutation() async {
182        do {
183            entries = try await service.fetchACLs(repositoryRid: repository.rid)
184            loadError = nil
185        } catch {
186            self.error = "Saved, but couldn't refresh access list: \(error.userFacingMessage)"
187        }
188    }
189
190    func merge(_ entry: RepositoryACLEntry) {
191        if let index = entries.firstIndex(where: { $0.id == entry.id }) {
192            entries[index] = entry
193        } else {
194            entries.append(entry)
195        }
196        entries = sort(entries)
197    }
198
199    func sort(_ entries: [RepositoryACLEntry]) -> [RepositoryACLEntry] {
200        entries.sorted {
201            $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending
202        }
203    }
204
205    static func normalizedIdentity(_ value: String) -> String {
206        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
207        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()).lowercased() : trimmed.lowercased()
208    }
209
210    func normalizedIdentity(_ value: String) -> String {
211        Self.normalizedIdentity(value)
212    }
213}