krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.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 id: id,
24 rid: rid,
25 service: service,
26 name: name,
27 description: description,
28 visibility: visibility,
29 updated: updated,
30 owner: owner,
31 head: head
32 )
33 }
34}
35
36private struct DeleteRepositoryResponse: Decodable, Sendable {
37 let deleteRepository: DeletedRepositoryPayload
38}
39
40private struct DeletedRepositoryPayload: Decodable, Sendable {
41 let id: Int
42}
43
44@Observable
45@MainActor
46final class RepositorySettingsViewModel {
47 let repositoryId: Int
48 let repositoryRid: String
49 let service: SRHTService
50
51 private let client: SRHTClient
52 private(set) var repository: RepositorySummary
53
54 var editedName: String
55 var editedDescription: String
56 var editedVisibility: Visibility
57 var editedHead: String
58
59 private(set) var branches: [ReferenceDetail]
60 var isSavingMetadata = false
61 var isSavingDefaultBranch = false
62 var isUpdatingVisibility = false
63 var isDeleting = false
64 var error: String?
65 var didDelete = false
66
67 var isMutating: Bool {
68 isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
69 }
70
71 var currentDefaultBranchName: String {
72 repository.defaultBranchName ?? "Not set"
73 }
74
75 var availableBranchNames: [String] {
76 branches.map { RepositorySummary.displayBranchName(for: $0.name) }
77 }
78
79 var normalizedEditedName: String {
80 editedName.trimmingCharacters(in: .whitespacesAndNewlines)
81 }
82
83 var normalizedEditedDescription: String {
84 editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
85 }
86
87 var metadataValidationMessage: String? {
88 Self.metadataValidationMessage(for: normalizedEditedName)
89 }
90
91 var defaultBranchValidationMessage: String? {
92 guard !branches.isEmpty else {
93 return "This repository doesn't have any branches yet."
94 }
95 let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
96 guard !normalizedHead.isEmpty else {
97 return "Select a default branch."
98 }
99 guard availableBranchNames.contains(normalizedHead) else {
100 return "Select one of the available branches."
101 }
102 return nil
103 }
104
105 var isMetadataDirty: Bool {
106 normalizedEditedName != repository.name ||
107 normalizedEditedDescription != (repository.description ?? "")
108 }
109
110 var isDefaultBranchDirty: Bool {
111 editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
112 }
113
114 var isVisibilityDirty: Bool {
115 editedVisibility != repository.visibility
116 }
117
118 init(
119 repository: RepositorySummary,
120 branches: [ReferenceDetail],
121 client: SRHTClient
122 ) {
123 self.repositoryId = repository.id
124 self.repositoryRid = repository.rid
125 self.service = repository.service
126 self.client = client
127 self.repository = repository
128 self.branches = branches
129 self.editedName = repository.name
130 self.editedDescription = repository.description ?? ""
131 self.editedVisibility = repository.visibility
132 self.editedHead = repository.defaultBranchName ?? ""
133 }
134
135 private static let updateRepositoryMutation = """
136 mutation updateRepository($id: Int!, $input: RepoInput!) {
137 updateRepository(id: $id, input: $input) {
138 id
139 rid
140 name
141 description
142 visibility
143 updated
144 HEAD { name target }
145 }
146 }
147 """
148
149 private static let deleteRepositoryMutation = """
150 mutation deleteRepository($id: Int!) {
151 deleteRepository(id: $id) { id }
152 }
153 """
154
155 func saveMetadata() async -> RepositorySummary? {
156 guard !isMutating else { return nil }
157 if let metadataValidationMessage {
158 error = metadataValidationMessage
159 return nil
160 }
161 guard isMetadataDirty else { return repository }
162
163 isSavingMetadata = true
164 defer { isSavingMetadata = false }
165 error = nil
166
167 let input: [String: any Sendable] = [
168 "name": normalizedEditedName,
169 "description": normalizedEditedDescription
170 ]
171
172 do {
173 return try await updateRepository(with: input)
174 } catch {
175 self.error = "Couldn't update repository details. \(error.userFacingMessage)"
176 return nil
177 }
178 }
179
180 func saveDefaultBranch() async -> RepositorySummary? {
181 guard !isMutating else { return nil }
182 if let defaultBranchValidationMessage {
183 error = defaultBranchValidationMessage
184 return nil
185 }
186 guard isDefaultBranchDirty else { return repository }
187 guard let headReference = selectedHeadReferenceForSave() else {
188 error = "Select one of the available branches."
189 return nil
190 }
191
192 isSavingDefaultBranch = true
193 defer { isSavingDefaultBranch = false }
194 error = nil
195
196 do {
197 return try await updateRepository(with: ["HEAD": headReference])
198 } catch {
199 self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
200 return nil
201 }
202 }
203
204 func updateVisibility() async -> RepositorySummary? {
205 guard !isMutating else { return nil }
206 guard isVisibilityDirty else { return repository }
207
208 isUpdatingVisibility = true
209 defer { isUpdatingVisibility = false }
210 error = nil
211
212 do {
213 return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
214 } catch {
215 self.error = "Couldn't update visibility. \(error.userFacingMessage)"
216 editedVisibility = repository.visibility
217 return nil
218 }
219 }
220
221 func deleteRepository() async {
222 guard !isMutating else { return }
223
224 isDeleting = true
225 defer { isDeleting = false }
226 error = nil
227
228 do {
229 _ = try await client.execute(
230 service: service,
231 query: Self.deleteRepositoryMutation,
232 variables: ["id": repositoryId],
233 responseType: DeleteRepositoryResponse.self
234 )
235 didDelete = true
236 } catch {
237 self.error = error.userFacingMessage
238 }
239 }
240
241 private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
242 let result = try await client.execute(
243 service: service,
244 query: Self.updateRepositoryMutation,
245 variables: ["id": repositoryId, "input": input],
246 responseType: UpdateRepositoryResponse.self
247 )
248 let updatedRepository = result.updateRepository.repositorySummary(
249 using: repository.owner,
250 service: service
251 )
252 apply(updatedRepository)
253 return updatedRepository
254 }
255
256 private func apply(_ updatedRepository: RepositorySummary) {
257 repository = updatedRepository
258 editedName = updatedRepository.name
259 editedDescription = updatedRepository.description ?? ""
260 editedVisibility = updatedRepository.visibility
261 editedHead = updatedRepository.defaultBranchName ?? ""
262 }
263
264 static func metadataValidationMessage(for name: String) -> String? {
265 guard !name.isEmpty else {
266 return "Enter a repository name."
267 }
268 guard !name.contains("/") else {
269 return "Repository names can't contain '/'."
270 }
271 guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
272 return "Repository names can't contain spaces."
273 }
274 return nil
275 }
276
277 static func gitHeadReference(from input: String) -> String {
278 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
279 guard !trimmed.isEmpty else { return trimmed }
280 if trimmed.hasPrefix("refs/") {
281 return trimmed
282 }
283 return "refs/heads/\(trimmed)"
284 }
285
286 func selectedHeadReferenceForSave() -> String? {
287 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
288 guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
289 return nil
290 }
291
292 return branches.first {
293 RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
294 }?.name
295 }
296}