krz/hutch

an ios client for sourcehut

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

v2.12.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}