krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.1: 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 var error: String?
94
95 // Available references for the branch/tag picker
96 private(set) var branches: [Reference] = []
97 private(set) var tags: [Reference] = []
98 private(set) var isLoadingRefs = false
99
100 init(repositoryRid: String, service: SRHTService, client: SRHTClient) {
101 self.repositoryRid = repositoryRid
102 self.service = service
103 self.client = client
104 }
105
106 // MARK: - Queries
107
108 private static let rootTreeQuery = """
109 query files($rid: ID!, $revspec: String!) {
110 repository(rid: $rid) {
111 revparse_single(revspec: $revspec) {
112 tree {
113 id
114 entries {
115 results {
116 id
117 name
118 mode
119 object {
120 type
121 __typename
122 id
123 shortId
124 ... on Tree {
125 entries {
126 results {
127 id
128 name
129 mode
130 object { type id shortId }
131 }
132 cursor
133 }
134 }
135 ... on TextBlob {
136 size
137 }
138 ... on BinaryBlob {
139 size
140 }
141 }
142 }
143 cursor
144 }
145 }
146 }
147 }
148 }
149 """
150
151 /// Query to fetch additional pages of tree entries by tree ID + cursor.
152 private static let treeEntriesPageQuery = """
153 query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) {
154 repository(rid: $rid) {
155 object(id: $treeId) {
156 type
157 id
158 ... on Tree {
159 entries(cursor: $cursor) {
160 results {
161 id
162 name
163 mode
164 object {
165 type
166 __typename
167 id
168 shortId
169 ... on Tree {
170 entries {
171 results {
172 id
173 name
174 mode
175 object { type id shortId }
176 }
177 cursor
178 }
179 }
180 ... on TextBlob {
181 size
182 }
183 ... on BinaryBlob {
184 size
185 }
186 }
187 }
188 cursor
189 }
190 }
191 }
192 }
193 }
194 """
195
196 private static let subtreeQuery = """
197 query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) {
198 repository(rid: $rid) {
199 object(id: $treeId) {
200 type
201 id
202 ... on Tree {
203 entries(cursor: $cursor) {
204 results {
205 id
206 name
207 mode
208 object {
209 type
210 __typename
211 id
212 shortId
213 ... on Tree {
214 entries {
215 results {
216 id
217 name
218 mode
219 object { type id shortId }
220 }
221 cursor
222 }
223 }
224 ... on TextBlob {
225 size
226 }
227 ... on BinaryBlob {
228 size
229 }
230 }
231 }
232 cursor
233 }
234 }
235 }
236 }
237 }
238 """
239
240 private static let blobQuery = """
241 query blob($rid: ID!, $blobId: String!) {
242 repository(rid: $rid) {
243 object(id: $blobId) {
244 type
245 id
246 ... on TextBlob {
247 text
248 size
249 }
250 ... on BinaryBlob {
251 size
252 content
253 }
254 }
255 }
256 }
257 """
258
259 private static let refsQuery = """
260 query refs($rid: ID!) {
261 repository(rid: $rid) {
262 references {
263 results { name target }
264 cursor
265 }
266 }
267 }
268 """
269
270 // MARK: - Load Root Tree
271
272 func loadRootTree() async {
273 guard !isLoading else { return }
274 isLoading = true
275 error = nil
276 viewingEntry = nil
277 viewingObject = nil
278 defer { isLoading = false }
279
280 let variables: [String: any Sendable] = [
281 "rid": repositoryRid,
282 "revspec": revspec
283 ]
284
285 do {
286 let result: RevparseResponse
287 do {
288 result = try await client.execute(
289 service: service,
290 query: Self.rootTreeQuery,
291 variables: variables,
292 responseType: RevparseResponse.self
293 )
294 } catch {
295 if isMissingGitReferenceError(error) {
296 navStack = [FileNavEntry(name: "root", treeId: "")]
297 entries = []
298 return
299 }
300 throw error
301 }
302 if let tree = result.repository?.revparseSingle?.tree,
303 let rootId = tree.id {
304 navStack = [FileNavEntry(name: "root", treeId: rootId)]
305 var allEntries = tree.entries?.results ?? []
306 var cursor = tree.entries?.cursor
307 // Follow cursor pagination for remaining pages
308 while let nextCursor = cursor {
309 let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor)
310 allEntries.append(contentsOf: pageEntries.results)
311 cursor = pageEntries.cursor
312 }
313 entries = allEntries
314 } else {
315 navStack = []
316 entries = []
317 }
318 } catch {
319 self.error = error.userFacingMessage
320 }
321 }
322
323 // MARK: - Navigate Into Folder
324
325 func navigateInto(entry: TreeEntry) async {
326 guard let object = entry.object else { return }
327
328 switch object {
329 case .tree(let tree):
330 // Use the git object SHA from entry.object, NOT entry.id
331 guard let objectSHA = tree.id else { return }
332 // If we already have the entries inline and no further pages, use them directly
333 if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil {
334 navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA))
335 entries = inlineEntries
336 viewingEntry = nil
337 viewingObject = nil
338 return
339 }
340 // Otherwise fetch the subtree (handles pagination)
341 await loadSubtree(name: entry.name, treeId: objectSHA)
342
343 case .textBlob(let blob):
344 if blob.text != nil {
345 viewingEntry = entry
346 viewingObject = object
347 } else if let blobId = blob.id {
348 await loadBlob(entry: entry, blobId: blobId)
349 }
350
351 case .binaryBlob(let blob):
352 if blob.content != nil || blob.id == nil {
353 viewingEntry = entry
354 viewingObject = object
355 } else if let blobId = blob.id {
356 await loadBlob(entry: entry, blobId: blobId)
357 }
358
359 case .unknown:
360 break
361 }
362 }
363
364 private func loadSubtree(name: String, treeId: String) async {
365 guard !isLoading else { return }
366 isLoading = true
367 error = nil
368 viewingEntry = nil
369 viewingObject = nil
370 defer { isLoading = false }
371
372 let variables: [String: any Sendable] = [
373 "rid": repositoryRid,
374 "treeId": treeId
375 ]
376
377 do {
378 let result = try await client.execute(
379 service: service,
380 query: Self.subtreeQuery,
381 variables: variables,
382 responseType: SubtreeResponse.self
383 )
384 navStack.append(FileNavEntry(name: name, treeId: treeId))
385 var allEntries = result.repository?.object?.entries?.results ?? []
386 var cursor = result.repository?.object?.entries?.cursor
387 while let nextCursor = cursor {
388 let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
389 allEntries.append(contentsOf: pageEntries.results)
390 cursor = pageEntries.cursor
391 }
392 entries = allEntries
393 } catch {
394 self.error = error.userFacingMessage
395 }
396 }
397
398 private func loadBlob(entry: TreeEntry, blobId: String) async {
399 guard !isLoading else { return }
400 isLoading = true
401 error = nil
402 defer { isLoading = false }
403
404 let variables: [String: any Sendable] = [
405 "rid": repositoryRid,
406 "blobId": blobId
407 ]
408
409 do {
410 let result = try await client.execute(
411 service: service,
412 query: Self.blobQuery,
413 variables: variables,
414 responseType: BlobResponse.self
415 )
416 viewingEntry = entry
417 viewingObject = result.repository?.object ?? .unknown
418 } catch {
419 self.error = error.userFacingMessage
420 }
421 }
422
423 // MARK: - Navigate to Breadcrumb
424
425 func navigateToBreadcrumb(at index: Int) async {
426 guard index >= 0, index < navStack.count else { return }
427
428 // If tapping current level, do nothing
429 if index == navStack.count - 1, viewingEntry == nil {
430 return
431 }
432
433 // Clear file view
434 viewingEntry = nil
435 viewingObject = nil
436
437 // Trim the stack
438 let targetEntry = navStack[index]
439 navStack = Array(navStack.prefix(index + 1))
440
441 if index == 0 {
442 // Go back to root — reload from revparse_single
443 await loadRootTree()
444 } else {
445 // Load the subtree at this level
446 isLoading = true
447 error = nil
448 defer { isLoading = false }
449
450 let variables: [String: any Sendable] = [
451 "rid": repositoryRid,
452 "treeId": targetEntry.treeId
453 ]
454
455 do {
456 let result = try await client.execute(
457 service: service,
458 query: Self.subtreeQuery,
459 variables: variables,
460 responseType: SubtreeResponse.self
461 )
462 var allEntries = result.repository?.object?.entries?.results ?? []
463 var cursor = result.repository?.object?.entries?.cursor
464 while let nextCursor = cursor {
465 let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
466 allEntries.append(contentsOf: pageEntries.results)
467 cursor = pageEntries.cursor
468 }
469 entries = allEntries
470 } catch {
471 self.error = error.userFacingMessage
472 }
473 }
474 }
475
476 /// Fetch a single page of tree entries by tree ID and cursor.
477 private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
478 let variables: [String: any Sendable] = [
479 "rid": repositoryRid,
480 "treeId": treeId,
481 "cursor": cursor
482 ]
483 let result = try await client.execute(
484 service: service,
485 query: Self.treeEntriesPageQuery,
486 variables: variables,
487 responseType: SubtreeResponse.self
488 )
489 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
490 }
491
492 // MARK: - Linked File (used by README link interception)
493
494 private static let linkedFileQuery = """
495 query linkedFile($rid: ID!, $revspec: String, $path: String!) {
496 repository(rid: $rid) {
497 path(revspec: $revspec, path: $path) {
498 id
499 name
500 mode
501 object {
502 __typename
503 ... on TextBlob { text size }
504 ... on BinaryBlob { size }
505 }
506 }
507 }
508 }
509 """
510
511 /// Fetch a single file by path using the `repository.path()` field.
512 /// Returns a `TreeEntry` on success, or throws on API/network error.
513 /// Returns `nil` if the path resolves to nothing (file not found).
514 func fetchLinkedFile(path: String, revspec: String) async throws -> TreeEntry? {
515 let variables: [String: any Sendable] = [
516 "rid": repositoryRid,
517 "revspec": revspec,
518 "path": path
519 ]
520 let result = try await client.execute(
521 service: service,
522 query: Self.linkedFileQuery,
523 variables: variables,
524 responseType: LinkedFileResponse.self
525 )
526 return result.repository?.path
527 }
528
529 /// Dismiss the file view and go back to the directory listing.
530 func dismissFileView() {
531 viewingEntry = nil
532 viewingObject = nil
533 }
534
535 // MARK: - Change Revspec
536
537 func changeRevspec(_ newRevspec: String) async {
538 revspec = newRevspec
539 await loadRootTree()
540 }
541
542 // MARK: - Load References
543
544 func loadReferences() async {
545 guard !isLoadingRefs else { return }
546 isLoadingRefs = true
547
548 do {
549 let result = try await client.execute(
550 service: service,
551 query: Self.refsQuery,
552 variables: ["rid": repositoryRid],
553 responseType: RefsResponseLocal.self
554 )
555 let allRefs = result.repository?.references.results ?? []
556 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
557 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
558 } catch {
559 // Silently fail for refs — non-critical
560 }
561
562 isLoadingRefs = false
563 }
564
565 private func isMissingGitReferenceError(_ error: Error) -> Bool {
566 guard let srhtError = error as? SRHTError else { return false }
567 guard case .graphQLErrors(let errors) = srhtError else { return false }
568 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
569 }
570}
571
572// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
573private struct RefsResponseLocal: Decodable, Sendable {
574 let repository: RefsRepoLocal?
575}
576
577private struct RefsRepoLocal: Decodable, Sendable {
578 let references: RefsPageLocal
579}
580
581private struct RefsPageLocal: Decodable, Sendable {
582 let results: [Reference]
583 let cursor: String?
584}