krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.0: Hutch/Views/Repositories/RepositorySettingsViewModel.swift · raw
1import Foundation
2
3private struct UpdateRepositoryResponse: Decodable, Sendable {
4 let updateRepository: UpdatedRepositoryPayload
5}
6
7private struct UpdatedRepositoryPayload: Decodable, Sendable {
8 let id: Int
9 let rid: String
10 let name: String
11 let description: String?
12 let visibility: Visibility
13 let updated: Date
14 let head: Reference?
15
16 enum CodingKeys: String, CodingKey {
17 case id, rid, name, description, visibility, updated
18 case head = "HEAD"
19 }
20
21 func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary {
22 RepositorySummary(
23 fields: .init(
24 id: id,
25 rid: rid,
26 service: service,
27 name: name,
28 description: description,
29 visibility: visibility,
30 updated: updated,
31 owner: owner,
32 head: head
33 )
34 )
35 }
36}
37
38private struct DeleteRepositoryResponse: Decodable, Sendable {
39 let deleteRepository: DeletedRepositoryPayload
40}
41
42private struct DeletedRepositoryPayload: Decodable, Sendable {
43 let id: Int
44}
45
46@Observable
47@MainActor
48final class RepositorySettingsViewModel {
49 let repositoryId: Int
50 let repositoryRid: String
51 let service: SRHTService
52
53 private let client: SRHTClient
54 private(set) var repository: RepositorySummary
55
56 var editedName: String
57 var editedDescription: String
58 var editedVisibility: Visibility
59 var editedHead: String
60
61 private(set) var branches: [ReferenceDetail]
62 var isSavingMetadata = false
63 var isSavingDefaultBranch = false
64 var isUpdatingVisibility = false
65 var isDeleting = false
66 var error: String?
67 var didDelete = false
68
69 var isMutating: Bool {
70 isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
71 }
72
73 var currentDefaultBranchName: String {
74 repository.defaultBranchName ?? "Not set"
75 }
76
77 var availableBranchNames: [String] {
78 branches.map { RepositorySummary.displayBranchName(for: $0.name) }
79 }
80
81 var normalizedEditedName: String {
82 editedName.trimmingCharacters(in: .whitespacesAndNewlines)
83 }
84
85 var normalizedEditedDescription: String {
86 editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
87 }
88
89 var metadataValidationMessage: String? {
90 Self.metadataValidationMessage(for: normalizedEditedName)
91 }
92
93 var defaultBranchValidationMessage: String? {
94 guard !branches.isEmpty else {
95 return "This repository doesn't have any branches yet."
96 }
97 let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
98 guard !normalizedHead.isEmpty else {
99 return "Select a default branch."
100 }
101 guard availableBranchNames.contains(normalizedHead) else {
102 return "Select one of the available branches."
103 }
104 return nil
105 }
106
107 var isMetadataDirty: Bool {
108 normalizedEditedName != repository.name ||
109 normalizedEditedDescription != (repository.description ?? "")
110 }
111
112 var isDefaultBranchDirty: Bool {
113 editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
114 }
115
116 var isVisibilityDirty: Bool {
117 editedVisibility != repository.visibility
118 }
119
120 init(
121 repository: RepositorySummary,
122 branches: [ReferenceDetail],
123 client: SRHTClient
124 ) {
125 self.repositoryId = repository.id
126 self.repositoryRid = repository.rid
127 self.service = repository.service
128 self.client = client
129 self.repository = repository
130 self.branches = branches
131 self.editedName = repository.name
132 self.editedDescription = repository.description ?? ""
133 self.editedVisibility = repository.visibility
134 self.editedHead = repository.defaultBranchName ?? ""
135 }
136
137 private static let updateRepositoryMutation = """
138 mutation updateRepository($id: Int!, $input: RepoInput!) {
139 updateRepository(id: $id, input: $input) {
140 id
141 rid
142 name
143 description
144 visibility
145 updated
146 HEAD { name target }
147 }
148 }
149 """
150
151 private static let deleteRepositoryMutation = """
152 mutation deleteRepository($id: Int!) {
153 deleteRepository(id: $id) { id }
154 }
155 """
156
157 func saveMetadata() async -> RepositorySummary? {
158 guard !isMutating else { return nil }
159 if let metadataValidationMessage {
160 error = metadataValidationMessage
161 return nil
162 }
163 guard isMetadataDirty else { return repository }
164
165 isSavingMetadata = true
166 defer { isSavingMetadata = false }
167 error = nil
168
169 let input = metadataInputForSave()
170
171 do {
172 return try await updateRepository(with: input)
173 } catch {
174 self.error = "Couldn't update repository details. \(error.userFacingMessage)"
175 return nil
176 }
177 }
178
179 func saveDefaultBranch() async -> RepositorySummary? {
180 guard !isMutating else { return nil }
181 if let defaultBranchValidationMessage {
182 error = defaultBranchValidationMessage
183 return nil
184 }
185 guard isDefaultBranchDirty else { return repository }
186 guard let headReference = selectedHeadReferenceForSave() else {
187 error = "Select one of the available branches."
188 return nil
189 }
190
191 isSavingDefaultBranch = true
192 defer { isSavingDefaultBranch = false }
193 error = nil
194
195 do {
196 return try await updateRepository(with: ["HEAD": headReference])
197 } catch {
198 self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
199 return nil
200 }
201 }
202
203 func updateVisibility() async -> RepositorySummary? {
204 guard !isMutating else { return nil }
205 guard isVisibilityDirty else { return repository }
206
207 isUpdatingVisibility = true
208 defer { isUpdatingVisibility = false }
209 error = nil
210
211 do {
212 return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
213 } catch {
214 self.error = "Couldn't update visibility. \(error.userFacingMessage)"
215 editedVisibility = repository.visibility
216 return nil
217 }
218 }
219
220 func deleteRepository() async {
221 guard !isMutating else { return }
222
223 isDeleting = true
224 defer { isDeleting = false }
225 error = nil
226
227 do {
228 _ = try await client.execute(
229 service: service,
230 query: Self.deleteRepositoryMutation,
231 variables: ["id": repositoryId],
232 responseType: DeleteRepositoryResponse.self
233 )
234 // The list and Home are both served from cache, so without this the
235 // repository lingers on screen after it no longer exists. Creation
236 // already does this; deletion never did.
237 await client.invalidateCache(prefix: APICacheKeys.prefix(service.rawValue, "repositories"))
238 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
239 didDelete = true
240 } catch {
241 self.error = error.userFacingMessage
242 }
243 }
244
245 private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
246 let result = try await client.execute(
247 service: service,
248 query: Self.updateRepositoryMutation,
249 variables: ["id": repositoryId, "input": input],
250 responseType: UpdateRepositoryResponse.self
251 )
252 let updatedRepository = result.updateRepository.repositorySummary(
253 using: repository.owner,
254 service: service
255 )
256 apply(updatedRepository)
257 return updatedRepository
258 }
259
260 private func apply(_ updatedRepository: RepositorySummary) {
261 repository = updatedRepository
262 editedName = updatedRepository.name
263 editedDescription = updatedRepository.description ?? ""
264 editedVisibility = updatedRepository.visibility
265 editedHead = updatedRepository.defaultBranchName ?? ""
266 }
267
268 static func metadataValidationMessage(for name: String) -> String? {
269 guard !name.isEmpty else {
270 return "Enter a repository name."
271 }
272 guard !name.contains("/") else {
273 return "Repository names can't contain '/'."
274 }
275 guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
276 return "Repository names can't contain spaces."
277 }
278 return nil
279 }
280
281 static func gitHeadReference(from input: String) -> String {
282 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
283 guard !trimmed.isEmpty else { return trimmed }
284 if trimmed.hasPrefix("refs/") {
285 return trimmed
286 }
287 return "refs/heads/\(trimmed)"
288 }
289
290 func selectedHeadReferenceForSave() -> String? {
291 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
292 guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
293 return nil
294 }
295
296 return branches.first {
297 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
298 }?.name
299 }
300
301 func metadataInputForSave() -> [String: any Sendable] {
302 var input: [String: any Sendable] = [:]
303
304 if normalizedEditedName != repository.name {
305 input["name"] = normalizedEditedName
306 }
307
308 if normalizedEditedDescription != (repository.description ?? "") {
309 if normalizedEditedDescription.isEmpty {
310 // Assigning nil through the subscript would remove the key and omit
311 // `description` from the mutation, leaving the old value in place.
312 // updateValue stores an explicit nil, which AnyCodable encodes as null.
313 input.updateValue(Optional<String>.none as any Sendable, forKey: "description")
314 } else {
315 input["description"] = normalizedEditedDescription
316 }
317 }
318
319 return input
320 }
321}