krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.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}