krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.0: Hutch/Views/Repositories/FileTreeViewModel.swift · raw
1import Foundation
2
3// MARK: - Linked file
4
5/// Identifies a repository file to fetch and display in a sheet.
6struct LinkedFileRequest: Identifiable, Sendable {
7 var id: String { "\(revspec):\(path)" }
8 let path: String
9 let revspec: String
10 let fileName: String
11}
12
13// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
14
15private struct LinkedFileResponse: Decodable, Sendable {
16 let repository: LinkedFileRepository?
17}
18
19private struct LinkedFileRepository: Decodable, Sendable {
20 let path: TreeEntry?
21}
22
23private struct RevparseResponse: Decodable, Sendable {
24 let repository: RevparseRepository?
25}
26
27private struct RevparseRepository: Decodable, Sendable {
28 let revparseSingle: RevparseCommit?
29
30 enum CodingKeys: String, CodingKey {
31 case revparseSingle = "revparse_single"
32 }
33}
34
35private struct RevparseCommit: Decodable, Sendable {
36 let tree: GitTree?
37}
38
39private struct SubtreeResponse: Decodable, Sendable {
40 let repository: SubtreeRepository?
41}
42
43private struct SubtreeRepository: Decodable, Sendable {
44 let object: SubtreeObject?
45}
46
47/// Dedicated decoding struct for the subtree query response.
48/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment.
49private struct SubtreeObject: Decodable, Sendable {
50 let entries: GitTreeEntryPage?
51}
52
53private struct BlobResponse: Decodable, Sendable {
54 let repository: BlobRepository?
55}
56
57private struct BlobRepository: Decodable, Sendable {
58 let object: GitObject?
59}
60
61// MARK: - Navigation Stack Entry
62
63struct FileNavEntry: Hashable {
64 let name: String
65 let treeId: String
66}
67
68// MARK: - View Model
69
70@Observable
71@MainActor
72final class FileTreeViewModel {
73
74 let repositoryRid: String
75 let service: SRHTService
76 private let client: SRHTClient
77
78 /// The current revspec. "HEAD" by default, or a full ref name.
79 var revspec: String = "HEAD"
80
81 /// Navigation stack: each entry is a (name, treeId) pair.
82 /// The first entry is always the root.
83 private(set) var navStack: [FileNavEntry] = []
84
85 /// The tree entries at the current directory level.
86 private(set) var entries: [TreeEntry] = []
87
88 /// When viewing a file (text blob or binary blob), this holds the object.
89 private(set) var viewingEntry: TreeEntry?
90 private(set) var viewingObject: GitObject?
91
92 private(set) var isLoading = false
93 private(set) var cacheMetadata: CacheEntryMetadata?
94 var error: String?
95
96 // Available references for the branch/tag picker
97 private(set) var branches: [Reference] = []
98 private(set) var tags: [Reference] = []
99 private(set) var isLoadingRefs = false
100
101 init(repositoryRid: String, service: SRHTService, client: SRHTClient) {
102 self.repositoryRid = repositoryRid
103 self.service = service
104 self.client = client
105 }
106
107 // MARK: - Queries
108
109 private static let rootTreeQuery = """
110 query files($rid: ID!, $revspec: String!) {
111 repository(rid: $rid) {
112 revparse_single(revspec: $revspec) {
113 tree {
114 id
115 entries {
116 results {
117 id
118 name
119 mode
120 object {
121 type
122 __typename
123 id
124 shortId
125 ... on Tree {
126 entries {
127 results {
128 id
129 name
130 mode
131 object { type id shortId }
132 }
133 cursor
134 }
135 }
136 ... on TextBlob {
137 size
138 }
139 ... on BinaryBlob {
140 size
141 }
142 }
143 }
144 cursor
145 }
146 }
147 }
148 }
149 }
150 """
151
152 /// Query to fetch additional pages of tree entries by tree ID + cursor.
153 private static let treeEntriesPageQuery = """
154 query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) {
155 repository(rid: $rid) {
156 object(id: $treeId) {
157 type
158 id
159 ... on Tree {
160 entries(cursor: $cursor) {
161 results {
162 id
163 name
164 mode
165 object {
166 type
167 __typename
168 id
169 shortId
170 ... on Tree {
171 entries {
172 results {
173 id
174 name
175 mode
176 object { type id shortId }
177 }
178 cursor
179 }
180 }
181 ... on TextBlob {
182 size
183 }
184 ... on BinaryBlob {
185 size
186 }
187 }
188 }
189 cursor
190 }
191 }
192 }
193 }
194 }
195 """
196
197 private static let subtreeQuery = """
198 query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) {
199 repository(rid: $rid) {
200 object(id: $treeId) {
201 type
202 id
203 ... on Tree {
204 entries(cursor: $cursor) {
205 results {
206 id
207 name
208 mode
209 object {
210 type
211 __typename
212 id
213 shortId
214 ... on Tree {
215 entries {
216 results {
217 id
218 name
219 mode
220 object { type id shortId }
221 }
222 cursor
223 }
224 }
225 ... on TextBlob {
226 size
227 }
228 ... on BinaryBlob {
229 size
230 }
231 }
232 }
233 cursor
234 }
235 }
236 }
237 }
238 }
239 """
240
241 private static let blobQuery = """
242 query blob($rid: ID!, $blobId: String!) {
243 repository(rid: $rid) {
244 object(id: $blobId) {
245 type
246 id
247 ... on TextBlob {
248 text
249 size
250 }
251 ... on BinaryBlob {
252 size
253 content
254 }
255 }
256 }
257 }
258 """
259
260 private static let refsQuery = """
261 query refs($rid: ID!) {
262 repository(rid: $rid) {
263 references {
264 results { name target }
265 cursor
266 }
267 }
268 }
269 """
270
271 // MARK: - Load Root Tree
272
273 func loadRootTree() async {
274 guard !isLoading else { return }
275 isLoading = true
276 error = nil
277 viewingEntry = nil
278 viewingObject = nil
279 defer { isLoading = false }
280
281 let variables: [String: any Sendable] = [
282 "rid": repositoryRid,
283 "revspec": revspec
284 ]
285
286 do {
287 let result: RevparseResponse
288 do {
289 let cached = try await client.executeCached(
290 service: service,
291 query: Self.rootTreeQuery,
292 variables: variables,
293 responseType: RevparseResponse.self,
294 cacheKey: APICacheKeys.treeRoot(service: service, rid: repositoryRid, ref: revspec),
295 resourceType: .repositoryTree,
296 ttl: APICacheTTLs.movingRefFileContent,
297 policy: .cacheFirstThenRefresh
298 )
299 result = cached.value
300 cacheMetadata = cached.metadata
301 } catch {
302 if isMissingGitReferenceError(error) {
303 navStack = [FileNavEntry(name: "root", treeId: "")]
304 entries = []
305 return
306 }
307 throw error
308 }
309 if let tree = result.repository?.revparseSingle?.tree,
310 let rootId = tree.id {
311 navStack = [FileNavEntry(name: "root", treeId: rootId)]
312 var allEntries = tree.entries?.results ?? []
313 var cursor = tree.entries?.cursor
314 // Follow cursor pagination for remaining pages
315 while let nextCursor = cursor {
316 let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor)
317 allEntries.append(contentsOf: pageEntries.results)
318 cursor = pageEntries.cursor
319 }
320 entries = allEntries
321 } else {
322 navStack = []
323 entries = []
324 }
325 } catch {
326 self.error = error.userFacingMessage
327 }
328 }
329
330 // MARK: - Navigate Into Folder
331
332 func navigateInto(entry: TreeEntry) async {
333 guard let object = entry.object else { return }
334
335 switch object {
336 case .tree(let tree):
337 // Use the git object SHA from entry.object, NOT entry.id
338 guard let objectSHA = tree.id else { return }
339 // If we already have the entries inline and no further pages, use them directly
340 if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil {
341 navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA))
342 entries = inlineEntries
343 viewingEntry = nil
344 viewingObject = nil
345 return
346 }
347 // Otherwise fetch the subtree (handles pagination)
348 await loadSubtree(name: entry.name, treeId: objectSHA)
349
350 case .textBlob(let blob):
351 if blob.text != nil {
352 viewingEntry = entry
353 viewingObject = object
354 } else if let blobId = blob.id {
355 await loadBlob(entry: entry, blobId: blobId)
356 }
357
358 case .binaryBlob(let blob):
359 if blob.content != nil || blob.id == nil {
360 viewingEntry = entry
361 viewingObject = object
362 } else if let blobId = blob.id {
363 await loadBlob(entry: entry, blobId: blobId)
364 }
365
366 case .unknown:
367 break
368 }
369 }
370
371 private func loadSubtree(name: String, treeId: String) async {
372 guard !isLoading else { return }
373 isLoading = true
374 error = nil
375 viewingEntry = nil
376 viewingObject = nil
377 defer { isLoading = false }
378
379 let variables: [String: any Sendable] = [
380 "rid": repositoryRid,
381 "treeId": treeId
382 ]
383
384 do {
385 let cached = try await client.executeCached(
386 service: service,
387 query: Self.subtreeQuery,
388 variables: variables,
389 responseType: SubtreeResponse.self,
390 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId),
391 resourceType: .repositoryTree,
392 ttl: APICacheTTLs.immutableFileContent,
393 policy: .cacheFirstThenRefresh
394 )
395 let result = cached.value
396 cacheMetadata = cached.metadata
397 navStack.append(FileNavEntry(name: name, treeId: treeId))
398 var allEntries = result.repository?.object?.entries?.results ?? []
399 var cursor = result.repository?.object?.entries?.cursor
400 while let nextCursor = cursor {
401 let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
402 allEntries.append(contentsOf: pageEntries.results)
403 cursor = pageEntries.cursor
404 }
405 entries = allEntries
406 } catch {
407 self.error = error.userFacingMessage
408 }
409 }
410
411 private func loadBlob(entry: TreeEntry, blobId: String) async {
412 guard !isLoading else { return }
413 isLoading = true
414 error = nil
415 defer { isLoading = false }
416
417 let variables: [String: any Sendable] = [
418 "rid": repositoryRid,
419 "blobId": blobId
420 ]
421
422 do {
423 let cached = try await client.executeCached(
424 service: service,
425 query: Self.blobQuery,
426 variables: variables,
427 responseType: BlobResponse.self,
428 cacheKey: APICacheKeys.blob(service: service, rid: repositoryRid, blobId: blobId),
429 resourceType: .repositoryFile,
430 ttl: APICacheTTLs.immutableFileContent,
431 policy: .cacheFirstThenRefresh
432 )
433 let result = cached.value
434 cacheMetadata = cached.metadata
435 viewingEntry = entry
436 viewingObject = result.repository?.object ?? .unknown
437 } catch {
438 self.error = error.userFacingMessage
439 }
440 }
441
442 // MARK: - Navigate to Breadcrumb
443
444 func navigateToBreadcrumb(at index: Int) async {
445 guard index >= 0, index < navStack.count else { return }
446
447 // If tapping current level, do nothing
448 if index == navStack.count - 1, viewingEntry == nil {
449 return
450 }
451
452 // Clear file view
453 viewingEntry = nil
454 viewingObject = nil
455
456 // Trim the stack
457 let targetEntry = navStack[index]
458 navStack = Array(navStack.prefix(index + 1))
459
460 if index == 0 {
461 // Go back to root — reload from revparse_single
462 await loadRootTree()
463 } else {
464 // Load the subtree at this level
465 isLoading = true
466 error = nil
467 defer { isLoading = false }
468
469 let variables: [String: any Sendable] = [
470 "rid": repositoryRid,
471 "treeId": targetEntry.treeId
472 ]
473
474 do {
475 let cached = try await client.executeCached(
476 service: service,
477 query: Self.subtreeQuery,
478 variables: variables,
479 responseType: SubtreeResponse.self,
480 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: targetEntry.treeId),
481 resourceType: .repositoryTree,
482 ttl: APICacheTTLs.immutableFileContent,
483 policy: .cacheFirstThenRefresh
484 )
485 let result = cached.value
486 cacheMetadata = cached.metadata
487 var allEntries = result.repository?.object?.entries?.results ?? []
488 var cursor = result.repository?.object?.entries?.cursor
489 while let nextCursor = cursor {
490 let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
491 allEntries.append(contentsOf: pageEntries.results)
492 cursor = pageEntries.cursor
493 }
494 entries = allEntries
495 } catch {
496 self.error = error.userFacingMessage
497 }
498 }
499 }
500
501 /// Fetch a single page of tree entries by tree ID and cursor.
502 private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
503 let variables: [String: any Sendable] = [
504 "rid": repositoryRid,
505 "treeId": treeId,
506 "cursor": cursor
507 ]
508 let cached = try await client.executeCached(
509 service: service,
510 query: Self.treeEntriesPageQuery,
511 variables: variables,
512 responseType: SubtreeResponse.self,
513 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId, cursor: cursor),
514 resourceType: .repositoryTree,
515 ttl: APICacheTTLs.immutableFileContent,
516 policy: .cacheFirstThenRefresh
517 )
518 return cached.value.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
519 }
520
521 // MARK: - Linked File (used by README link interception)
522
523 private static let linkedFileQuery = """
524 query linkedFile($rid: ID!, $revspec: String, $path: String!) {
525 repository(rid: $rid) {
526 path(revspec: $revspec, path: $path) {
527 id
528 name
529 mode
530 object {
531 __typename
532 ... on TextBlob { text size }
533 ... on BinaryBlob { size }
534 }
535 }
536 }
537 }
538 """
539
540 /// Fetch a single file by path using the `repository.path()` field.
541 /// Returns a `TreeEntry` on success, or throws on API/network error.
542 /// Returns `nil` if the path resolves to nothing (file not found).
543 func fetchLinkedFile(path: String, revspec: String) async throws -> TreeEntry? {
544 let variables: [String: any Sendable] = [
545 "rid": repositoryRid,
546 "revspec": revspec,
547 "path": path
548 ]
549 let cached = try await client.executeCached(
550 service: service,
551 query: Self.linkedFileQuery,
552 variables: variables,
553 responseType: LinkedFileResponse.self,
554 cacheKey: APICacheKeys.path(service: service, rid: repositoryRid, ref: revspec, path: path),
555 resourceType: .repositoryFile,
556 ttl: revspec == "HEAD" ? APICacheTTLs.movingRefFileContent : APICacheTTLs.immutableFileContent,
557 policy: .cacheFirstThenRefresh
558 )
559 return cached.value.repository?.path
560 }
561
562 /// Dismiss the file view and go back to the directory listing.
563 func dismissFileView() {
564 viewingEntry = nil
565 viewingObject = nil
566 }
567
568 // MARK: - Change Revspec
569
570 func changeRevspec(_ newRevspec: String) async {
571 revspec = newRevspec
572 await loadRootTree()
573 }
574
575 // MARK: - Load References
576
577 func loadReferences() async {
578 guard !isLoadingRefs else { return }
579 isLoadingRefs = true
580
581 do {
582 let result = try await client.execute(
583 service: service,
584 query: Self.refsQuery,
585 variables: ["rid": repositoryRid],
586 responseType: RefsResponseLocal.self
587 )
588 let allRefs = result.repository?.references.results ?? []
589 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
590 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
591 } catch {
592 // Silently fail for refs — non-critical
593 }
594
595 isLoadingRefs = false
596 }
597
598 private func isMissingGitReferenceError(_ error: Error) -> Bool {
599 guard let srhtError = error as? SRHTError else { return false }
600 guard case .graphQLErrors(let errors) = srhtError else { return false }
601 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
602 }
603}
604
605// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
606private struct RefsResponseLocal: Decodable, Sendable {
607 let repository: RefsRepoLocal?
608}
609
610private struct RefsRepoLocal: Decodable, Sendable {
611 let references: RefsPageLocal
612}
613
614private struct RefsPageLocal: Decodable, Sendable {
615 let results: [Reference]
616 let cursor: String?
617}