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