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}