krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.6: 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}