krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift · raw
1import Foundation
2
3private struct HgUpdateRepositoryResponse: Decodable, Sendable {
4 let updateRepository: HgUpdatedRepository
5}
6
7private struct HgUpdatedRepository: Decodable, Sendable {
8 let id: Int
9}
10
11private struct HgRepositoryInfoResponse: Decodable, Sendable {
12 let repository: HgRepositoryInfo?
13}
14
15private struct HgRepositoryInfo: Decodable, Sendable {
16 let description: String?
17 let visibility: Visibility
18 let nonPublishing: Bool?
19}
20
21private struct HgACLResponse: Decodable, Sendable {
22 let repository: HgACLRepository?
23}
24
25private struct HgACLRepository: Decodable, Sendable {
26 let accessControlList: HgACLPage
27}
28
29private struct HgACLPage: Decodable, Sendable {
30 let results: [HgACLEntry]
31 let cursor: String?
32}
33
34private struct HgUpdateACLResponse: Decodable, Sendable {
35 let updateACL: HgACLEntry
36}
37
38private struct HgDeleteACLResponse: Decodable, Sendable {
39 let deleteACL: HgDeletedACL
40}
41
42private struct HgDeletedACL: Decodable, Sendable {
43 let id: Int
44}
45
46private struct HgDeleteRepositoryResponse: Decodable, Sendable {
47 let deleteRepository: HgDeletedRepository
48}
49
50private struct HgDeletedRepository: Decodable, Sendable {
51 let id: Int
52}
53
54struct HgACLEntry: Decodable, Sendable, Identifiable {
55 let id: Int
56 let mode: String
57 let entity: Entity
58}
59
60@Observable
61@MainActor
62final class HgRepositorySettingsViewModel {
63 let repositoryId: Int
64 let repositoryRid: String
65 let repositoryName: String
66 private let client: SRHTClient
67
68 var editedDescription: String
69 var editedVisibility: Visibility
70 var editedNonPublishing: Bool
71 var isSavingInfo = false
72
73 private(set) var acls: [HgACLEntry] = []
74 private(set) var isLoadingACLs = false
75 var newACLEntity = ""
76 var newACLMode = "RO"
77 var isAddingACL = false
78 var isDeletingACL = false
79
80 var histeditRevision = ""
81 var isDeleting = false
82 var didDelete = false
83 var error: String?
84
85 init(repository: RepositorySummary, client: SRHTClient) {
86 self.repositoryId = repository.id
87 self.repositoryRid = repository.rid
88 self.repositoryName = repository.name
89 self.client = client
90 self.editedDescription = repository.description ?? ""
91 self.editedVisibility = repository.visibility
92 self.editedNonPublishing = false
93 }
94
95 private static let updateRepositoryMutation = """
96 mutation updateRepository($id: Int!, $input: RepoInput!) {
97 updateRepository(id: $id, input: $input) {
98 id
99 }
100 }
101 """
102
103 private static let accessControlListQuery = """
104 query hgAccessControlList($rid: ID!) {
105 repository(rid: $rid) {
106 accessControlList {
107 results {
108 id
109 mode
110 entity { canonicalName }
111 }
112 cursor
113 }
114 }
115 }
116 """
117
118 private static let updateACLMutation = """
119 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
120 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
121 id
122 mode
123 entity { canonicalName }
124 }
125 }
126 """
127
128 private static let deleteACLMutation = """
129 mutation deleteACL($id: Int!) {
130 deleteACL(id: $id) { id }
131 }
132 """
133
134 private static let deleteRepositoryMutation = """
135 mutation deleteRepository($id: Int!) {
136 deleteRepository(id: $id) { id }
137 }
138 """
139
140 private static let repositoryInfoQuery = """
141 query hgRepositoryInfo($rid: ID!) {
142 repository(rid: $rid) {
143 description
144 visibility
145 nonPublishing
146 }
147 }
148 """
149
150 func loadRepositoryInfo() async {
151 error = nil
152
153 do {
154 let result = try await client.execute(
155 service: .hg,
156 query: Self.repositoryInfoQuery,
157 variables: ["rid": repositoryRid],
158 responseType: HgRepositoryInfoResponse.self
159 )
160
161 if let repository = result.repository {
162 editedDescription = repository.description ?? ""
163 editedVisibility = repository.visibility
164 editedNonPublishing = repository.nonPublishing ?? false
165 }
166 } catch {
167 self.error = error.localizedDescription
168 }
169 }
170
171 func saveInfo() async {
172 isSavingInfo = true
173 defer { isSavingInfo = false }
174 error = nil
175
176 do {
177 let input: [String: any Sendable] = [
178 "description": editedDescription,
179 "visibility": editedVisibility.rawValue,
180 "nonPublishing": editedNonPublishing
181 ]
182 _ = try await client.execute(
183 service: .hg,
184 query: Self.updateRepositoryMutation,
185 variables: ["id": repositoryId, "input": input],
186 responseType: HgUpdateRepositoryResponse.self
187 )
188 } catch {
189 self.error = error.localizedDescription
190 }
191 }
192
193 func loadACLs() async {
194 guard !isLoadingACLs else { return }
195 isLoadingACLs = true
196 defer { isLoadingACLs = false }
197 error = nil
198
199 do {
200 let result = try await client.execute(
201 service: .hg,
202 query: Self.accessControlListQuery,
203 variables: ["rid": repositoryRid],
204 responseType: HgACLResponse.self
205 )
206 acls = result.repository?.accessControlList.results ?? []
207 } catch {
208 self.error = error.localizedDescription
209 }
210 }
211
212 func addACL() async {
213 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
214 guard !rawEntity.isEmpty else { return }
215 let entity = hgCanonicalEntity(from: rawEntity)
216 isAddingACL = true
217 defer { isAddingACL = false }
218 error = nil
219
220 do {
221 let result = try await client.execute(
222 service: .hg,
223 query: Self.updateACLMutation,
224 variables: [
225 "repoId": repositoryId,
226 "mode": newACLMode,
227 "entity": entity
228 ],
229 responseType: HgUpdateACLResponse.self
230 )
231 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
232 acls[index] = result.updateACL
233 } else {
234 acls.append(result.updateACL)
235 }
236 newACLEntity = ""
237 } catch {
238 let message = error.localizedDescription
239 if message.localizedCaseInsensitiveContains("No such repository or user found") {
240 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
241 } else {
242 self.error = message
243 }
244 }
245 }
246
247 private func hgCanonicalEntity(from input: String) -> String {
248 let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
249 return "~\(username)"
250 }
251
252 func deleteACL(_ entry: HgACLEntry) async {
253 isDeletingACL = true
254 defer { isDeletingACL = false }
255 error = nil
256
257 do {
258 _ = try await client.execute(
259 service: .hg,
260 query: Self.deleteACLMutation,
261 variables: ["id": entry.id],
262 responseType: HgDeleteACLResponse.self
263 )
264 acls.removeAll { $0.id == entry.id }
265 } catch {
266 self.error = error.localizedDescription
267 }
268 }
269
270 func deleteRepository() async {
271 isDeleting = true
272 defer { isDeleting = false }
273 error = nil
274
275 do {
276 _ = try await client.execute(
277 service: .hg,
278 query: Self.deleteRepositoryMutation,
279 variables: ["id": repositoryId],
280 responseType: HgDeleteRepositoryResponse.self
281 )
282 didDelete = true
283 } catch {
284 self.error = error.localizedDescription
285 }
286 }
287}