krz/hutch

an ios client for sourcehut

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

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