krz/hutch

an ios client for sourcehut

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

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