krz/hutch

an ios client for sourcehut

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

bb8fae4d1145266de4f5117a3b8c48f2ccacb0be

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T00:28:42Z

lazily load file blobs from repository tree views
 Hutch/Models/Git.swift                           | 19 ++++++-----
 Hutch/Views/Repositories/FileTreeView.swift      |  2 +-
 Hutch/Views/Repositories/FileTreeViewModel.swift | 24 ++++++++------
 HutchTests/GitObjectTests.swift                  | 40 ++++++++++++++++++++++++
 4 files changed, 66 insertions(+), 19 deletions(-)

diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift
index 19ec31b..905822e 100644
--- a/Hutch/Models/Git.swift
+++ b/Hutch/Models/Git.swift
@@ -113,7 +113,7 @@ struct GitTree: Codable, Sendable {
 struct GitTextBlob: Codable, Sendable {
     let id: String?
     let shortId: String?
-    let text: String
+    let text: String?
     let size: Int?
 }
 
@@ -134,14 +134,16 @@ struct GitTreeEntryPage: Codable, Sendable {
 extension GitObject: Codable {
     private enum CodingKeys: String, CodingKey {
         case type, id, shortId, entries, text, size, content
+        case typename = "__typename"
     }
 
     init(from decoder: any Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         let type = try container.decodeIfPresent(String.self, forKey: .type)
+        let typename = try container.decodeIfPresent(String.self, forKey: .typename)
 
-        switch type {
-        case "TREE":
+        switch type ?? typename {
+        case "TREE", "Tree":
             let tree = GitTree(
                 id: try container.decodeIfPresent(String.self, forKey: .id),
                 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
@@ -149,13 +151,12 @@ extension GitObject: Codable {
             )
             self = .tree(tree)
 
-        case "BLOB":
-            // TextBlob has a "text" key; BinaryBlob does not
-            if container.contains(.text) {
+        case "BLOB", "TextBlob", "BinaryBlob":
+            if typename == "TextBlob" || container.contains(.text) {
                 let blob = GitTextBlob(
                     id: try container.decodeIfPresent(String.self, forKey: .id),
                     shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
-                    text: try container.decode(String.self, forKey: .text),
+                    text: try container.decodeIfPresent(String.self, forKey: .text),
                     size: try container.decodeIfPresent(Int.self, forKey: .size)
                 )
                 self = .textBlob(blob)
@@ -184,12 +185,14 @@ extension GitObject: Codable {
             try container.encodeIfPresent(tree.entries, forKey: .entries)
         case .textBlob(let blob):
             try container.encode("BLOB", forKey: .type)
+            try container.encode("TextBlob", forKey: .typename)
             try container.encodeIfPresent(blob.id, forKey: .id)
             try container.encodeIfPresent(blob.shortId, forKey: .shortId)
-            try container.encode(blob.text, forKey: .text)
+            try container.encodeIfPresent(blob.text, forKey: .text)
             try container.encodeIfPresent(blob.size, forKey: .size)
         case .binaryBlob(let blob):
             try container.encode("BLOB", forKey: .type)
+            try container.encode("BinaryBlob", forKey: .typename)
             try container.encodeIfPresent(blob.id, forKey: .id)
             try container.encodeIfPresent(blob.shortId, forKey: .shortId)
             try container.encodeIfPresent(blob.size, forKey: .size)
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
index 3750adb..45fe5b6 100644
--- a/Hutch/Views/Repositories/FileTreeView.swift
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -235,7 +235,7 @@ private struct FileTreeContentView: View {
 
             GeometryReader { geometry in
                 ScrollView([.vertical, .horizontal]) {
-                    Text(blob.text)
+                    Text(blob.text ?? "")
                         .font(.system(.body, design: .monospaced))
                         .multilineTextAlignment(.leading)
                         .fixedSize(horizontal: true, vertical: false)
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
index f270779..a9c86b4 100644
--- a/Hutch/Views/Repositories/FileTreeViewModel.swift
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -96,6 +96,7 @@ final class FileTreeViewModel {
                             mode
                             object {
                                 type
+                                __typename
                                 id
                                 shortId
                                 ... on Tree {
@@ -110,12 +111,10 @@ final class FileTreeViewModel {
                                     }
                                 }
                                 ... on TextBlob {
-                                    text
                                     size
                                 }
                                 ... on BinaryBlob {
                                     size
-                                    content
                                 }
                             }
                         }
@@ -142,6 +141,7 @@ final class FileTreeViewModel {
                             mode
                             object {
                                 type
+                                __typename
                                 id
                                 shortId
                                 ... on Tree {
@@ -156,12 +156,10 @@ final class FileTreeViewModel {
                                     }
                                 }
                                 ... on TextBlob {
-                                    text
                                     size
                                 }
                                 ... on BinaryBlob {
                                     size
-                                    content
                                 }
                             }
                         }
@@ -187,6 +185,7 @@ final class FileTreeViewModel {
                             mode
                             object {
                                 type
+                                __typename
                                 id
                                 shortId
                                 ... on Tree {
@@ -201,12 +200,10 @@ final class FileTreeViewModel {
                                     }
                                 }
                                 ... on TextBlob {
-                                    text
                                     size
                                 }
                                 ... on BinaryBlob {
                                     size
-                                    content
                                 }
                             }
                         }
@@ -321,16 +318,23 @@ final class FileTreeViewModel {
             // Otherwise fetch the subtree (handles pagination)
             await loadSubtree(name: entry.name, treeId: objectSHA)
 
-        case .textBlob:
-            viewingEntry = entry
-            viewingObject = object
+        case .textBlob(let blob):
+            if blob.text != nil {
+                viewingEntry = entry
+                viewingObject = object
+            } else if let blobId = blob.id {
+                await loadBlob(entry: entry, blobId: blobId)
+            }
 
         case .binaryBlob(let blob):
-            if blob.content != nil || blob.size != nil {
+            if blob.content != nil {
                 viewingEntry = entry
                 viewingObject = object
             } else if let blobId = blob.id {
                 await loadBlob(entry: entry, blobId: blobId)
+            } else {
+                viewingEntry = entry
+                viewingObject = object
             }
 
         case .unknown:
diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift
new file mode 100644
index 0000000..547c6f3
--- /dev/null
+++ b/HutchTests/GitObjectTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct GitObjectTests {
+
+    @Test
+    @MainActor
+    func decodesMetadataOnlyTextBlobUsingTypename() throws {
+        let data = Data(#"{"type":"BLOB","__typename":"TextBlob","id":"blob123","shortId":"blob123","size":42}"#.utf8)
+
+        let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+        guard case .textBlob(let textBlob) = blob else {
+            Issue.record("Expected metadata-only blob to decode as text blob.")
+            return
+        }
+
+        #expect(textBlob.id == "blob123")
+        #expect(textBlob.size == 42)
+        #expect(textBlob.text == nil)
+    }
+
+    @Test
+    @MainActor
+    func decodesMetadataOnlyBinaryBlobUsingTypename() throws {
+        let data = Data(#"{"type":"BLOB","__typename":"BinaryBlob","id":"blob456","shortId":"blob456","size":64}"#.utf8)
+
+        let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+        guard case .binaryBlob(let binaryBlob) = blob else {
+            Issue.record("Expected metadata-only blob to decode as binary blob.")
+            return
+        }
+
+        #expect(binaryBlob.id == "blob456")
+        #expect(binaryBlob.size == 64)
+        #expect(binaryBlob.content == nil)
+    }
+}