krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: 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 didDelete = true
235 } catch {
236 self.error = error.userFacingMessage
237 }
238 }
239
240 private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
241 let result = try await client.execute(
242 service: service,
243 query: Self.updateRepositoryMutation,
244 variables: ["id": repositoryId, "input": input],
245 responseType: UpdateRepositoryResponse.self
246 )
247 let updatedRepository = result.updateRepository.repositorySummary(
248 using: repository.owner,
249 service: service
250 )
251 apply(updatedRepository)
252 return updatedRepository
253 }
254
255 private func apply(_ updatedRepository: RepositorySummary) {
256 repository = updatedRepository
257 editedName = updatedRepository.name
258 editedDescription = updatedRepository.description ?? ""
259 editedVisibility = updatedRepository.visibility
260 editedHead = updatedRepository.defaultBranchName ?? ""
261 }
262
263 static func metadataValidationMessage(for name: String) -> String? {
264 guard !name.isEmpty else {
265 return "Enter a repository name."
266 }
267 guard !name.contains("/") else {
268 return "Repository names can't contain '/'."
269 }
270 guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
271 return "Repository names can't contain spaces."
272 }
273 return nil
274 }
275
276 static func gitHeadReference(from input: String) -> String {
277 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
278 guard !trimmed.isEmpty else { return trimmed }
279 if trimmed.hasPrefix("refs/") {
280 return trimmed
281 }
282 return "refs/heads/\(trimmed)"
283 }
284
285 func selectedHeadReferenceForSave() -> String? {
286 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
287 guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
288 return nil
289 }
290
291 return branches.first {
292 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
293 }?.name
294 }
295
296 func metadataInputForSave() -> [String: any Sendable] {
297 var input: [String: any Sendable] = [:]
298
299 if normalizedEditedName != repository.name {
300 input["name"] = normalizedEditedName
301 }
302
303 if normalizedEditedDescription != (repository.description ?? "") {
304 input["description"] = normalizedEditedDescription.isEmpty ? Optional<String>.none as String? : normalizedEditedDescription
305 }
306
307 return input
308 }
309}