Commit cb8fedaefe

cb8fedaefe6562f5281e3bee8d0ed75e9a506576

parent: 9656d31eb5

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 00:28 UTC

lazily load file blobs from repository tree views

Layout: unified · split

Hutch/Models/Git.swift +11 −8
@@ -113,7 +113,7 @@ struct GitTree: Codable, Sendable {
113struct GitTextBlob: Codable, Sendable { 113struct GitTextBlob: Codable, Sendable {
114 let id: String? 114 let id: String?
115 let shortId: String? 115 let shortId: String?
116 let text: String 116 let text: String?
117 let size: Int? 117 let size: Int?
118} 118}
119 119
@@ -134,14 +134,16 @@ struct GitTreeEntryPage: Codable, Sendable {
134extension GitObject: Codable { 134extension GitObject: Codable {
135 private enum CodingKeys: String, CodingKey { 135 private enum CodingKeys: String, CodingKey {
136 case type, id, shortId, entries, text, size, content 136 case type, id, shortId, entries, text, size, content
137 case typename = "__typename"
137 } 138 }
138 139
139 init(from decoder: any Decoder) throws { 140 init(from decoder: any Decoder) throws {
140 let container = try decoder.container(keyedBy: CodingKeys.self) 141 let container = try decoder.container(keyedBy: CodingKeys.self)
141 let type = try container.decodeIfPresent(String.self, forKey: .type) 142 let type = try container.decodeIfPresent(String.self, forKey: .type)
143 let typename = try container.decodeIfPresent(String.self, forKey: .typename)
142 144
143 switch type { 145 switch type ?? typename {
144 case "TREE": 146 case "TREE", "Tree":
145 let tree = GitTree( 147 let tree = GitTree(
146 id: try container.decodeIfPresent(String.self, forKey: .id), 148 id: try container.decodeIfPresent(String.self, forKey: .id),
147 shortId: try container.decodeIfPresent(String.self, forKey: .shortId), 149 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
@@ -149,13 +151,12 @@ extension GitObject: Codable {
149 ) 151 )
150 self = .tree(tree) 152 self = .tree(tree)
151 153
152 case "BLOB": 154 case "BLOB", "TextBlob", "BinaryBlob":
153 // TextBlob has a "text" key; BinaryBlob does not 155 if typename == "TextBlob" || container.contains(.text) {
154 if container.contains(.text) {
155 let blob = GitTextBlob( 156 let blob = GitTextBlob(
156 id: try container.decodeIfPresent(String.self, forKey: .id), 157 id: try container.decodeIfPresent(String.self, forKey: .id),
157 shortId: try container.decodeIfPresent(String.self, forKey: .shortId), 158 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
158 text: try container.decode(String.self, forKey: .text), 159 text: try container.decodeIfPresent(String.self, forKey: .text),
159 size: try container.decodeIfPresent(Int.self, forKey: .size) 160 size: try container.decodeIfPresent(Int.self, forKey: .size)
160 ) 161 )
161 self = .textBlob(blob) 162 self = .textBlob(blob)
@@ -184,12 +185,14 @@ extension GitObject: Codable {
184 try container.encodeIfPresent(tree.entries, forKey: .entries) 185 try container.encodeIfPresent(tree.entries, forKey: .entries)
185 case .textBlob(let blob): 186 case .textBlob(let blob):
186 try container.encode("BLOB", forKey: .type) 187 try container.encode("BLOB", forKey: .type)
188 try container.encode("TextBlob", forKey: .typename)
187 try container.encodeIfPresent(blob.id, forKey: .id) 189 try container.encodeIfPresent(blob.id, forKey: .id)
188 try container.encodeIfPresent(blob.shortId, forKey: .shortId) 190 try container.encodeIfPresent(blob.shortId, forKey: .shortId)
189 try container.encode(blob.text, forKey: .text) 191 try container.encodeIfPresent(blob.text, forKey: .text)
190 try container.encodeIfPresent(blob.size, forKey: .size) 192 try container.encodeIfPresent(blob.size, forKey: .size)
191 case .binaryBlob(let blob): 193 case .binaryBlob(let blob):
192 try container.encode("BLOB", forKey: .type) 194 try container.encode("BLOB", forKey: .type)
195 try container.encode("BinaryBlob", forKey: .typename)
193 try container.encodeIfPresent(blob.id, forKey: .id) 196 try container.encodeIfPresent(blob.id, forKey: .id)
194 try container.encodeIfPresent(blob.shortId, forKey: .shortId) 197 try container.encodeIfPresent(blob.shortId, forKey: .shortId)
195 try container.encodeIfPresent(blob.size, forKey: .size) 198 try container.encodeIfPresent(blob.size, forKey: .size)
Hutch/Views/Repositories/FileTreeView.swift +1 −1
@@ -235,7 +235,7 @@ private struct FileTreeContentView: View {
235 235
236 GeometryReader { geometry in 236 GeometryReader { geometry in
237 ScrollView([.vertical, .horizontal]) { 237 ScrollView([.vertical, .horizontal]) {
238 Text(blob.text) 238 Text(blob.text ?? "")
239 .font(.system(.body, design: .monospaced)) 239 .font(.system(.body, design: .monospaced))
240 .multilineTextAlignment(.leading) 240 .multilineTextAlignment(.leading)
241 .fixedSize(horizontal: true, vertical: false) 241 .fixedSize(horizontal: true, vertical: false)
Hutch/Views/Repositories/FileTreeViewModel.swift +14 −10
@@ -96,6 +96,7 @@ final class FileTreeViewModel {
96 mode 96 mode
97 object { 97 object {
98 type 98 type
99 __typename
99 id 100 id
100 shortId 101 shortId
101 ... on Tree { 102 ... on Tree {
@@ -110,12 +111,10 @@ final class FileTreeViewModel {
110 } 111 }
111 } 112 }
112 ... on TextBlob { 113 ... on TextBlob {
113 text
114 size 114 size
115 } 115 }
116 ... on BinaryBlob { 116 ... on BinaryBlob {
117 size 117 size
118 content
119 } 118 }
120 } 119 }
121 } 120 }
@@ -142,6 +141,7 @@ final class FileTreeViewModel {
142 mode 141 mode
143 object { 142 object {
144 type 143 type
144 __typename
145 id 145 id
146 shortId 146 shortId
147 ... on Tree { 147 ... on Tree {
@@ -156,12 +156,10 @@ final class FileTreeViewModel {
156 } 156 }
157 } 157 }
158 ... on TextBlob { 158 ... on TextBlob {
159 text
160 size 159 size
161 } 160 }
162 ... on BinaryBlob { 161 ... on BinaryBlob {
163 size 162 size
164 content
165 } 163 }
166 } 164 }
167 } 165 }
@@ -187,6 +185,7 @@ final class FileTreeViewModel {
187 mode 185 mode
188 object { 186 object {
189 type 187 type
188 __typename
190 id 189 id
191 shortId 190 shortId
192 ... on Tree { 191 ... on Tree {
@@ -201,12 +200,10 @@ final class FileTreeViewModel {
201 } 200 }
202 } 201 }
203 ... on TextBlob { 202 ... on TextBlob {
204 text
205 size 203 size
206 } 204 }
207 ... on BinaryBlob { 205 ... on BinaryBlob {
208 size 206 size
209 content
210 } 207 }
211 } 208 }
212 } 209 }
@@ -321,16 +318,23 @@ final class FileTreeViewModel {
321 // Otherwise fetch the subtree (handles pagination) 318 // Otherwise fetch the subtree (handles pagination)
322 await loadSubtree(name: entry.name, treeId: objectSHA) 319 await loadSubtree(name: entry.name, treeId: objectSHA)
323 320
324 case .textBlob: 321 case .textBlob(let blob):
325 viewingEntry = entry 322 if blob.text != nil {
326 viewingObject = object 323 viewingEntry = entry
324 viewingObject = object
325 } else if let blobId = blob.id {
326 await loadBlob(entry: entry, blobId: blobId)
327 }
327 328
328 case .binaryBlob(let blob): 329 case .binaryBlob(let blob):
329 if blob.content != nil || blob.size != nil { 330 if blob.content != nil {
330 viewingEntry = entry 331 viewingEntry = entry
331 viewingObject = object 332 viewingObject = object
332 } else if let blobId = blob.id { 333 } else if let blobId = blob.id {
333 await loadBlob(entry: entry, blobId: blobId) 334 await loadBlob(entry: entry, blobId: blobId)
335 } else {
336 viewingEntry = entry
337 viewingObject = object
334 } 338 }
335 339
336 case .unknown: 340 case .unknown:
HutchTests/GitObjectTests.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct GitObjectTests {
6
7 @Test
8 @MainActor
9 func decodesMetadataOnlyTextBlobUsingTypename() throws {
10 let data = Data(#"{"type":"BLOB","__typename":"TextBlob","id":"blob123","shortId":"blob123","size":42}"#.utf8)
11
12 let blob = try JSONDecoder().decode(GitObject.self, from: data)
13
14 guard case .textBlob(let textBlob) = blob else {
15 Issue.record("Expected metadata-only blob to decode as text blob.")
16 return
17 }
18
19 #expect(textBlob.id == "blob123")
20 #expect(textBlob.size == 42)
21 #expect(textBlob.text == nil)
22 }
23
24 @Test
25 @MainActor
26 func decodesMetadataOnlyBinaryBlobUsingTypename() throws {
27 let data = Data(#"{"type":"BLOB","__typename":"BinaryBlob","id":"blob456","shortId":"blob456","size":64}"#.utf8)
28
29 let blob = try JSONDecoder().decode(GitObject.self, from: data)
30
31 guard case .binaryBlob(let binaryBlob) = blob else {
32 Issue.record("Expected metadata-only blob to decode as binary blob.")
33 return
34 }
35
36 #expect(binaryBlob.id == "blob456")
37 #expect(binaryBlob.size == 64)
38 #expect(binaryBlob.content == nil)
39 }
40}