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