krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.6: 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: [String: any Sendable] = [
170 "name": normalizedEditedName,
171 "description": normalizedEditedDescription
172 ]
173
174 do {
175 return try await updateRepository(with: input)
176 } catch {
177 self.error = "Couldn't update repository details. \(error.userFacingMessage)"
178 return nil
179 }
180 }
181
182 func saveDefaultBranch() async -> RepositorySummary? {
183 guard !isMutating else { return nil }
184 if let defaultBranchValidationMessage {
185 error = defaultBranchValidationMessage
186 return nil
187 }
188 guard isDefaultBranchDirty else { return repository }
189 guard let headReference = selectedHeadReferenceForSave() else {
190 error = "Select one of the available branches."
191 return nil
192 }
193
194 isSavingDefaultBranch = true
195 defer { isSavingDefaultBranch = false }
196 error = nil
197
198 do {
199 return try await updateRepository(with: ["HEAD": headReference])
200 } catch {
201 self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
202 return nil
203 }
204 }
205
206 func updateVisibility() async -> RepositorySummary? {
207 guard !isMutating else { return nil }
208 guard isVisibilityDirty else { return repository }
209
210 isUpdatingVisibility = true
211 defer { isUpdatingVisibility = false }
212 error = nil
213
214 do {
215 return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
216 } catch {
217 self.error = "Couldn't update visibility. \(error.userFacingMessage)"
218 editedVisibility = repository.visibility
219 return nil
220 }
221 }
222
223 func deleteRepository() async {
224 guard !isMutating else { return }
225
226 isDeleting = true
227 defer { isDeleting = false }
228 error = nil
229
230 do {
231 _ = try await client.execute(
232 service: service,
233 query: Self.deleteRepositoryMutation,
234 variables: ["id": repositoryId],
235 responseType: DeleteRepositoryResponse.self
236 )
237 didDelete = true
238 } catch {
239 self.error = error.userFacingMessage
240 }
241 }
242
243 private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
244 let result = try await client.execute(
245 service: service,
246 query: Self.updateRepositoryMutation,
247 variables: ["id": repositoryId, "input": input],
248 responseType: UpdateRepositoryResponse.self
249 )
250 let updatedRepository = result.updateRepository.repositorySummary(
251 using: repository.owner,
252 service: service
253 )
254 apply(updatedRepository)
255 return updatedRepository
256 }
257
258 private func apply(_ updatedRepository: RepositorySummary) {
259 repository = updatedRepository
260 editedName = updatedRepository.name
261 editedDescription = updatedRepository.description ?? ""
262 editedVisibility = updatedRepository.visibility
263 editedHead = updatedRepository.defaultBranchName ?? ""
264 }
265
266 static func metadataValidationMessage(for name: String) -> String? {
267 guard !name.isEmpty else {
268 return "Enter a repository name."
269 }
270 guard !name.contains("/") else {
271 return "Repository names can't contain '/'."
272 }
273 guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
274 return "Repository names can't contain spaces."
275 }
276 return nil
277 }
278
279 static func gitHeadReference(from input: String) -> String {
280 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
281 guard !trimmed.isEmpty else { return trimmed }
282 if trimmed.hasPrefix("refs/") {
283 return trimmed
284 }
285 return "refs/heads/\(trimmed)"
286 }
287
288 func selectedHeadReferenceForSave() -> String? {
289 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
290 guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
291 return nil
292 }
293
294 return branches.first {
295 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
296 }?.name
297 }
298}