krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.1: Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift · raw
1import Foundation
2
3private struct HgRepositorySummaryResponse: Decodable, Sendable {
4 let repository: HgRepositorySummaryPayload?
5}
6
7private struct HgRepositorySummaryPayload: Decodable, Sendable {
8 let id: Int
9 let rid: String
10 let name: String
11 let description: String?
12 let visibility: Visibility
13 let readme: String?
14 let nonPublishing: Bool?
15 let tip: HgSummaryTip?
16 let branches: HgNamedRevisionPage?
17 let tags: HgNamedRevisionPage?
18 let bookmarks: HgNamedRevisionPage?
19}
20
21private struct HgSummaryTip: Decodable, Sendable {
22 let id: String?
23 let author: String?
24 let description: String?
25 let branch: String?
26 let tags: [String]?
27
28 var resolvedRevision: HgRevision? {
29 guard
30 let id,
31 let author,
32 let description
33 else {
34 return nil
35 }
36
37 return HgRevision(
38 id: id,
39 author: author,
40 description: description,
41 branch: branch,
42 tags: tags
43 )
44 }
45}
46
47private struct HgRevisionLogResponse: Decodable, Sendable {
48 let repository: HgRevisionLogRepository?
49}
50
51private struct HgRevisionLogRepository: Decodable, Sendable {
52 let log: HgRevisionPage?
53}
54
55private struct HgReadmeFileResponse: Decodable, Sendable {
56 let repository: HgReadmeFileRepository?
57}
58
59private struct HgReadmeFileRepository: Decodable, Sendable {
60 let readme: String?
61}
62
63private struct HgFilesResponse: Decodable, Sendable {
64 let repository: HgFilesRepository?
65}
66
67private struct HgFilesRepository: Decodable, Sendable {
68 let files: HgFilePage?
69}
70
71private struct HgFilePage: Decodable, Sendable {
72 let results: [HgFile]
73 let cursor: String?
74}
75
76private struct HgNamedRevisionPage: Decodable, Sendable {
77 let results: [HgNamedRevision]
78 let cursor: String?
79
80 private enum CodingKeys: String, CodingKey {
81 case results
82 case cursor
83 }
84
85 init(results: [HgNamedRevision], cursor: String?) {
86 self.results = results
87 self.cursor = cursor
88 }
89
90 init(from decoder: any Decoder) throws {
91 let container = try decoder.container(keyedBy: CodingKeys.self)
92 self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? []
93 self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
94 }
95}
96
97private struct HgCatResponse: Decodable, Sendable {
98 let repository: HgCatRepository?
99}
100
101private struct HgCatRepository: Decodable, Sendable {
102 let cat: String?
103}
104
105struct HgRevisionPage: Decodable, Sendable {
106 let results: [HgRevision]
107 let cursor: String?
108}
109
110struct HgRevision: Decodable, Sendable, Identifiable, Hashable {
111 let id: String
112 let author: String
113 let description: String
114 let branch: String?
115 let tags: [String]?
116
117 var displayShortId: String {
118 String(id.prefix(12))
119 }
120
121 var title: String {
122 description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
123 }
124
125 var body: String? {
126 let body = description
127 .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
128 .dropFirst()
129 .first
130 .map(String.init)?
131 .trimmingCharacters(in: .whitespacesAndNewlines)
132 return body?.isEmpty == false ? body : nil
133 }
134
135 var primaryName: String {
136 if let tag = tags?.first, !tag.isEmpty {
137 return tag
138 }
139 if let branch, !branch.isEmpty {
140 return branch
141 }
142 return displayShortId
143 }
144}
145
146struct HgFile: Decodable, Sendable, Hashable, Identifiable {
147 let name: String
148
149 var id: String { name }
150
151 var isDirectory: Bool {
152 name.hasSuffix("/")
153 }
154}
155
156struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable {
157 let name: String
158 let id: String
159
160 var displayShortId: String {
161 String(id.prefix(12))
162 }
163}
164
165@Observable
166@MainActor
167final class HgRepositoryDetailViewModel {
168 enum Tab: String, CaseIterable {
169 case summary = "Summary"
170 case browse = "Browse"
171 case log = "Log"
172 case tags = "Tags"
173 case branches = "Branches"
174 case bookmarks = "Bookmarks"
175 }
176
177 enum ReadmeContent {
178 case html(String)
179 case markdown(String)
180 case org(String)
181 case plainText(String)
182 }
183
184 let repository: RepositorySummary
185 private let client: SRHTClient
186
187 private(set) var summaryLoaded = false
188 private(set) var isLoadingSummary = false
189 private(set) var readmeContent: ReadmeContent?
190 private(set) var readmePath: String?
191 private(set) var nonPublishing = false
192 private(set) var tip: HgRevision?
193 private(set) var branches: [HgNamedRevision] = []
194 private(set) var tags: [HgNamedRevision] = []
195 private(set) var bookmarks: [HgNamedRevision] = []
196
197 private(set) var log: [HgRevision] = []
198 private(set) var isLoadingLog = false
199 private(set) var isLoadingMoreLog = false
200 private var logCursor: String?
201 private var hasMoreLog = true
202
203 private(set) var currentBrowsePath = ""
204 private(set) var pathStack: [String] = []
205 private(set) var files: [HgFile] = []
206 private(set) var fileContent: String?
207 private(set) var selectedFilePath: String?
208 private(set) var isLoadingBrowse = false
209 private(set) var browseRevspec = "tip"
210
211 var error: String?
212
213 init(repository: RepositorySummary, client: SRHTClient) {
214 self.repository = repository
215 self.client = client
216 }
217
218 private static let summaryQuery = """
219 query hgRepositorySummary($rid: ID!) {
220 repository(rid: $rid) {
221 id
222 rid
223 name
224 description
225 visibility
226 readme
227 nonPublishing
228 tip {
229 id
230 author
231 description
232 branch
233 tags
234 }
235 branches {
236 results {
237 name
238 id
239 }
240 cursor
241 }
242 tags {
243 results {
244 name
245 id
246 }
247 cursor
248 }
249 bookmarks {
250 results {
251 name
252 id
253 }
254 cursor
255 }
256 }
257 }
258 """
259
260 private static let logQuery = """
261 query hgRepositoryLog($rid: ID!, $cursor: Cursor) {
262 repository(rid: $rid) {
263 log(cursor: $cursor) {
264 results {
265 id
266 author
267 description
268 branch
269 tags
270 }
271 cursor
272 }
273 }
274 }
275 """
276
277 private static func readmeFileQuery(filename: String) -> String {
278 """
279 query hgReadmeFile($rid: ID!) {
280 repository(rid: $rid) {
281 readme: cat(path: "\(filename)", revspec: "tip")
282 }
283 }
284 """
285 }
286
287 private static let readmeFilenames = [
288 "README.md", "README.org", "README.txt", "README",
289 "readme.md", "readme.org"
290 ]
291
292 private static let filesQuery = """
293 query hgFiles($rid: ID!, $path: String!, $revspec: String!) {
294 repository(rid: $rid) {
295 files(path: $path, revspec: $revspec) {
296 results {
297 name
298 }
299 cursor
300 }
301 }
302 }
303 """
304
305 private static let catQuery = """
306 query hgCat($rid: ID!, $path: String!, $revspec: String!) {
307 repository(rid: $rid) {
308 cat(path: $path, revspec: $revspec)
309 }
310 }
311 """
312
313 func loadSummary() async {
314 guard !isLoadingSummary, !summaryLoaded else { return }
315 isLoadingSummary = true
316 defer { isLoadingSummary = false }
317 error = nil
318
319 do {
320 let result = try await client.execute(
321 service: .hg,
322 query: Self.summaryQuery,
323 variables: ["rid": repository.rid],
324 responseType: HgRepositorySummaryResponse.self
325 )
326
327 guard let repository = result.repository else {
328 summaryLoaded = true
329 return
330 }
331
332 tip = repository.tip?.resolvedRevision
333 branches = repository.branches?.results ?? []
334 tags = repository.tags?.results ?? []
335 bookmarks = repository.bookmarks?.results ?? []
336 nonPublishing = repository.nonPublishing ?? false
337
338 if let html = repository.readme, !html.isEmpty {
339 readmePath = nil
340 readmeContent = .html(html)
341 } else {
342 await loadReadmeFile()
343 }
344
345 summaryLoaded = true
346 } catch {
347 self.error = error.userFacingMessage
348 }
349 }
350
351 func loadLog() async {
352 guard !isLoadingLog else { return }
353 isLoadingLog = true
354 defer { isLoadingLog = false }
355 error = nil
356 logCursor = nil
357 hasMoreLog = true
358
359 do {
360 let page = try await fetchLogPage(cursor: nil)
361 log = page.results
362 logCursor = page.cursor
363 hasMoreLog = page.cursor != nil
364 } catch {
365 if isEmptyRepositoryError(error) {
366 log = []
367 logCursor = nil
368 hasMoreLog = false
369 } else {
370 self.error = error.userFacingMessage
371 }
372 }
373 }
374
375 func loadMoreLogIfNeeded(currentItem: HgRevision) async {
376 guard let last = log.last,
377 last.id == currentItem.id,
378 hasMoreLog,
379 !isLoadingMoreLog else {
380 return
381 }
382
383 isLoadingMoreLog = true
384 defer { isLoadingMoreLog = false }
385
386 do {
387 let page = try await fetchLogPage(cursor: logCursor)
388 log.append(contentsOf: page.results)
389 logCursor = page.cursor
390 hasMoreLog = page.cursor != nil
391 } catch {
392 self.error = error.userFacingMessage
393 }
394 }
395
396 private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage {
397 var variables: [String: any Sendable] = ["rid": repository.rid]
398 if let cursor {
399 variables["cursor"] = cursor
400 }
401
402 let result = try await client.execute(
403 service: .hg,
404 query: Self.logQuery,
405 variables: variables,
406 responseType: HgRevisionLogResponse.self
407 )
408 return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil)
409 }
410
411 private func loadReadmeFile() async {
412 for filename in Self.readmeFilenames {
413 do {
414 let result = try await client.execute(
415 service: .hg,
416 query: Self.readmeFileQuery(filename: filename),
417 variables: ["rid": repository.rid],
418 responseType: HgReadmeFileResponse.self
419 )
420
421 if let text = result.repository?.readme, !text.isEmpty {
422 readmePath = filename
423 if filename.hasSuffix(".md") {
424 readmeContent = .markdown(text)
425 } else if filename.hasSuffix(".org") {
426 readmeContent = .org(text)
427 } else {
428 readmeContent = .plainText(text)
429 }
430 return
431 }
432 } catch {
433 if isEmptyRepositoryError(error) {
434 readmeContent = nil
435 readmePath = nil
436 return
437 }
438 continue
439 }
440 }
441 }
442
443 func loadBrowseRoot() async {
444 await loadFiles(at: "")
445 }
446
447 func openFile(_ file: HgFile) async {
448 let path = joinedPath(for: file.name)
449 if file.isDirectory {
450 await loadFiles(at: path)
451 return
452 }
453
454 isLoadingBrowse = true
455 defer { isLoadingBrowse = false }
456 error = nil
457
458 do {
459 let result = try await client.execute(
460 service: .hg,
461 query: Self.catQuery,
462 variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
463 responseType: HgCatResponse.self
464 )
465
466 if let text = result.repository?.cat {
467 selectedFilePath = path
468 fileContent = text
469 } else {
470 await loadFiles(at: path)
471 }
472 } catch {
473 self.error = error.userFacingMessage
474 }
475 }
476
477 func navigateToPath(index: Int) async {
478 guard index >= 0, index <= pathStack.count else { return }
479 let targetPath = Array(pathStack.prefix(index)).joined(separator: "/")
480 await loadFiles(at: targetPath)
481 }
482
483 func dismissFileView() {
484 selectedFilePath = nil
485 fileContent = nil
486 }
487
488 func changeBrowseRevspec(_ newRevspec: String) async {
489 guard browseRevspec != newRevspec else { return }
490 browseRevspec = newRevspec
491 await loadBrowseRoot()
492 }
493
494 private func loadFiles(at path: String) async {
495 isLoadingBrowse = true
496 defer { isLoadingBrowse = false }
497 error = nil
498 selectedFilePath = nil
499 fileContent = nil
500
501 do {
502 let result = try await client.execute(
503 service: .hg,
504 query: Self.filesQuery,
505 variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
506 responseType: HgFilesResponse.self
507 )
508
509 currentBrowsePath = path
510 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
511 files = result.repository?.files?.results ?? []
512 } catch {
513 if isEmptyRepositoryError(error) {
514 currentBrowsePath = path
515 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
516 files = []
517 } else {
518 self.error = error.userFacingMessage
519 }
520 }
521 }
522
523 private func joinedPath(for name: String) -> String {
524 let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name
525 return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)"
526 }
527
528 private func isEmptyRepositoryError(_ error: Error) -> Bool {
529 if let srhtError = error as? SRHTError,
530 case .graphQLErrors(let errors) = srhtError {
531 return errors.contains {
532 let message = $0.message.localizedLowercase
533 return message.contains("missing")
534 || message.contains("not found")
535 || message.contains("unknown revision")
536 || message.contains("unknown revision or path not in the working tree")
537 }
538 }
539
540 let message = error.localizedDescription.localizedLowercase
541 return message.contains("missing")
542 || message.contains("not found")
543 || message.contains("unknown revision")
544 }
545}