Commit cb8fedaefe
Unsigned
Layout: unified · split
Hutch/Models/Git.swift +11 −8
| @@ -113,7 +113,7 @@ struct GitTree: Codable, Sendable { | ||
| 113 | 113 | struct GitTextBlob: Codable, Sendable { |
| 114 | 114 | let id: String? |
| 115 | 115 | let shortId: String? |
| 116 | let text: String | |
| 116 | let text: String? | |
| 117 | 117 | let size: Int? |
| 118 | 118 | } |
| 119 | 119 | |
| @@ -134,14 +134,16 @@ struct GitTreeEntryPage: Codable, Sendable { | ||
| 134 | 134 | extension GitObject: Codable { |
| 135 | 135 | private enum CodingKeys: String, CodingKey { |
| 136 | 136 | case type, id, shortId, entries, text, size, content |
| 137 | case typename = "__typename" | |
| 137 | 138 | } |
| 138 | 139 | |
| 139 | 140 | init(from decoder: any Decoder) throws { |
| 140 | 141 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| 141 | 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 { | |
| 144 | case "TREE": | |
| 145 | switch type ?? typename { | |
| 146 | case "TREE", "Tree": | |
| 145 | 147 | let tree = GitTree( |
| 146 | 148 | id: try container.decodeIfPresent(String.self, forKey: .id), |
| 147 | 149 | shortId: try container.decodeIfPresent(String.self, forKey: .shortId), |
| @@ -149,13 +151,12 @@ extension GitObject: Codable { | ||
| 149 | 151 | ) |
| 150 | 152 | self = .tree(tree) |
| 151 | 153 | |
| 152 | case "BLOB": | |
| 153 | // TextBlob has a "text" key; BinaryBlob does not | |
| 154 | if container.contains(.text) { | |
| 154 | case "BLOB", "TextBlob", "BinaryBlob": | |
| 155 | if typename == "TextBlob" || container.contains(.text) { | |
| 155 | 156 | let blob = GitTextBlob( |
| 156 | 157 | id: try container.decodeIfPresent(String.self, forKey: .id), |
| 157 | 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 | 160 | size: try container.decodeIfPresent(Int.self, forKey: .size) |
| 160 | 161 | ) |
| 161 | 162 | self = .textBlob(blob) |
| @@ -184,12 +185,14 @@ extension GitObject: Codable { | ||
| 184 | 185 | try container.encodeIfPresent(tree.entries, forKey: .entries) |
| 185 | 186 | case .textBlob(let blob): |
| 186 | 187 | try container.encode("BLOB", forKey: .type) |
| 188 | try container.encode("TextBlob", forKey: .typename) | |
| 187 | 189 | try container.encodeIfPresent(blob.id, forKey: .id) |
| 188 | 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 | 192 | try container.encodeIfPresent(blob.size, forKey: .size) |
| 191 | 193 | case .binaryBlob(let blob): |
| 192 | 194 | try container.encode("BLOB", forKey: .type) |
| 195 | try container.encode("BinaryBlob", forKey: .typename) | |
| 193 | 196 | try container.encodeIfPresent(blob.id, forKey: .id) |
| 194 | 197 | try container.encodeIfPresent(blob.shortId, forKey: .shortId) |
| 195 | 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 | 236 | GeometryReader { geometry in |
| 237 | 237 | ScrollView([.vertical, .horizontal]) { |
| 238 | Text(blob.text) | |
| 238 | Text(blob.text ?? "") | |
| 239 | 239 | .font(.system(.body, design: .monospaced)) |
| 240 | 240 | .multilineTextAlignment(.leading) |
| 241 | 241 | .fixedSize(horizontal: true, vertical: false) |
Hutch/Views/Repositories/FileTreeViewModel.swift +14 −10
| @@ -96,6 +96,7 @@ final class FileTreeViewModel { | ||
| 96 | 96 | mode |
| 97 | 97 | object { |
| 98 | 98 | type |
| 99 | __typename | |
| 99 | 100 | id |
| 100 | 101 | shortId |
| 101 | 102 | ... on Tree { |
| @@ -110,12 +111,10 @@ final class FileTreeViewModel { | ||
| 110 | 111 | } |
| 111 | 112 | } |
| 112 | 113 | ... on TextBlob { |
| 113 | text | |
| 114 | 114 | size |
| 115 | 115 | } |
| 116 | 116 | ... on BinaryBlob { |
| 117 | 117 | size |
| 118 | content | |
| 119 | 118 | } |
| 120 | 119 | } |
| 121 | 120 | } |
| @@ -142,6 +141,7 @@ final class FileTreeViewModel { | ||
| 142 | 141 | mode |
| 143 | 142 | object { |
| 144 | 143 | type |
| 144 | __typename | |
| 145 | 145 | id |
| 146 | 146 | shortId |
| 147 | 147 | ... on Tree { |
| @@ -156,12 +156,10 @@ final class FileTreeViewModel { | ||
| 156 | 156 | } |
| 157 | 157 | } |
| 158 | 158 | ... on TextBlob { |
| 159 | text | |
| 160 | 159 | size |
| 161 | 160 | } |
| 162 | 161 | ... on BinaryBlob { |
| 163 | 162 | size |
| 164 | content | |
| 165 | 163 | } |
| 166 | 164 | } |
| 167 | 165 | } |
| @@ -187,6 +185,7 @@ final class FileTreeViewModel { | ||
| 187 | 185 | mode |
| 188 | 186 | object { |
| 189 | 187 | type |
| 188 | __typename | |
| 190 | 189 | id |
| 191 | 190 | shortId |
| 192 | 191 | ... on Tree { |
| @@ -201,12 +200,10 @@ final class FileTreeViewModel { | ||
| 201 | 200 | } |
| 202 | 201 | } |
| 203 | 202 | ... on TextBlob { |
| 204 | text | |
| 205 | 203 | size |
| 206 | 204 | } |
| 207 | 205 | ... on BinaryBlob { |
| 208 | 206 | size |
| 209 | content | |
| 210 | 207 | } |
| 211 | 208 | } |
| 212 | 209 | } |
| @@ -321,16 +318,23 @@ final class FileTreeViewModel { | ||
| 321 | 318 | // Otherwise fetch the subtree (handles pagination) |
| 322 | 319 | await loadSubtree(name: entry.name, treeId: objectSHA) |
| 323 | 320 | |
| 324 | case .textBlob: | |
| 325 | viewingEntry = entry | |
| 326 | viewingObject = object | |
| 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 | } | |
| 327 | 328 | |
| 328 | 329 | case .binaryBlob(let blob): |
| 329 | if blob.content != nil || blob.size != nil { | |
| 330 | if blob.content != nil { | |
| 330 | 331 | viewingEntry = entry |
| 331 | 332 | viewingObject = object |
| 332 | 333 | } else if let blobId = blob.id { |
| 333 | 334 | await loadBlob(entry: entry, blobId: blobId) |
| 335 | } else { | |
| 336 | viewingEntry = entry | |
| 337 | viewingObject = object | |
| 334 | 338 | } |
| 335 | 339 | |
| 336 | 340 | case .unknown: |
HutchTests/GitObjectTests.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct 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 | } | |