krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.4: 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 || blob.id == nil {
331 viewingEntry = entry
332 viewingObject = object
333 } else if let blobId = blob.id {
334 await loadBlob(entry: entry, blobId: blobId)
335 }
336
337 case .unknown:
338 break
339 }
340 }
341
342 private func loadSubtree(name: String, treeId: String) async {
343 guard !isLoading else { return }
344 isLoading = true
345 error = nil
346 viewingEntry = nil
347 viewingObject = nil
348 defer { isLoading = false }
349
350 let variables: [String: any Sendable] = [
351 "rid": repositoryRid,
352 "treeId": treeId
353 ]
354
355 do {
356 let result = try await client.execute(
357 service: service,
358 query: Self.subtreeQuery,
359 variables: variables,
360 responseType: SubtreeResponse.self
361 )
362 navStack.append(FileNavEntry(name: name, treeId: treeId))
363 var allEntries = result.repository?.object?.entries?.results ?? []
364 var cursor = result.repository?.object?.entries?.cursor
365 while let nextCursor = cursor {
366 let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
367 allEntries.append(contentsOf: pageEntries.results)
368 cursor = pageEntries.cursor
369 }
370 entries = allEntries
371 } catch {
372 self.error = error.userFacingMessage
373 }
374 }
375
376 private func loadBlob(entry: TreeEntry, blobId: String) async {
377 guard !isLoading else { return }
378 isLoading = true
379 error = nil
380 defer { isLoading = false }
381
382 let variables: [String: any Sendable] = [
383 "rid": repositoryRid,
384 "blobId": blobId
385 ]
386
387 do {
388 let result = try await client.execute(
389 service: service,
390 query: Self.blobQuery,
391 variables: variables,
392 responseType: BlobResponse.self
393 )
394 viewingEntry = entry
395 viewingObject = result.repository?.object ?? .unknown
396 } catch {
397 self.error = error.userFacingMessage
398 }
399 }
400
401 // MARK: - Navigate to Breadcrumb
402
403 func navigateToBreadcrumb(at index: Int) async {
404 guard index >= 0, index < navStack.count else { return }
405
406 // If tapping current level, do nothing
407 if index == navStack.count - 1, viewingEntry == nil {
408 return
409 }
410
411 // Clear file view
412 viewingEntry = nil
413 viewingObject = nil
414
415 // Trim the stack
416 let targetEntry = navStack[index]
417 navStack = Array(navStack.prefix(index + 1))
418
419 if index == 0 {
420 // Go back to root — reload from revparse_single
421 await loadRootTree()
422 } else {
423 // Load the subtree at this level
424 isLoading = true
425 error = nil
426 defer { isLoading = false }
427
428 let variables: [String: any Sendable] = [
429 "rid": repositoryRid,
430 "treeId": targetEntry.treeId
431 ]
432
433 do {
434 let result = try await client.execute(
435 service: service,
436 query: Self.subtreeQuery,
437 variables: variables,
438 responseType: SubtreeResponse.self
439 )
440 var allEntries = result.repository?.object?.entries?.results ?? []
441 var cursor = result.repository?.object?.entries?.cursor
442 while let nextCursor = cursor {
443 let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
444 allEntries.append(contentsOf: pageEntries.results)
445 cursor = pageEntries.cursor
446 }
447 entries = allEntries
448 } catch {
449 self.error = error.userFacingMessage
450 }
451 }
452 }
453
454 /// Fetch a single page of tree entries by tree ID and cursor.
455 private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
456 let variables: [String: any Sendable] = [
457 "rid": repositoryRid,
458 "treeId": treeId,
459 "cursor": cursor
460 ]
461 let result = try await client.execute(
462 service: service,
463 query: Self.treeEntriesPageQuery,
464 variables: variables,
465 responseType: SubtreeResponse.self
466 )
467 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
468 }
469
470 /// Dismiss the file view and go back to the directory listing.
471 func dismissFileView() {
472 viewingEntry = nil
473 viewingObject = nil
474 }
475
476 // MARK: - Change Revspec
477
478 func changeRevspec(_ newRevspec: String) async {
479 revspec = newRevspec
480 await loadRootTree()
481 }
482
483 // MARK: - Load References
484
485 func loadReferences() async {
486 guard !isLoadingRefs else { return }
487 isLoadingRefs = true
488
489 do {
490 let result = try await client.execute(
491 service: service,
492 query: Self.refsQuery,
493 variables: ["rid": repositoryRid],
494 responseType: RefsResponseLocal.self
495 )
496 let allRefs = result.repository?.references.results ?? []
497 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
498 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
499 } catch {
500 // Silently fail for refs — non-critical
501 }
502
503 isLoadingRefs = false
504 }
505
506 private func isMissingGitReferenceError(_ error: Error) -> Bool {
507 guard let srhtError = error as? SRHTError else { return false }
508 guard case .graphQLErrors(let errors) = srhtError else { return false }
509 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
510 }
511}
512
513// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
514private struct RefsResponseLocal: Decodable, Sendable {
515 let repository: RefsRepoLocal?
516}
517
518private struct RefsRepoLocal: Decodable, Sendable {
519 let references: RefsPageLocal
520}
521
522private struct RefsPageLocal: Decodable, Sendable {
523 let results: [Reference]
524 let cursor: String?
525}