diff --git a/supacode/Clients/Repositories/RepositoryIconAssetStore.swift b/supacode/Clients/Repositories/RepositoryIconAssetStore.swift index 2aafedcb..20370dd5 100644 --- a/supacode/Clients/Repositories/RepositoryIconAssetStore.swift +++ b/supacode/Clients/Repositories/RepositoryIconAssetStore.swift @@ -39,23 +39,21 @@ nonisolated struct RepositoryIconAssetStore: Sendable { ) -> Bool } -nonisolated enum RepositoryIconAssetStoreError: Error, Equatable { - case unsupportedExtension(String) -} - nonisolated extension RepositoryIconAssetStore { - /// Allowed input extensions. PNG and SVG only — JPEG and other - /// formats either don't suit repo icon use (no transparency) or - /// don't render well at small sidebar sizes. - static let supportedExtensions: Set = ["png", "svg"] - static var liveValue: RepositoryIconAssetStore { RepositoryIconAssetStore( importImage: { sourceURL, rootURL in - let normalizedExt = sourceURL.pathExtension.lowercased() - guard Self.supportedExtensions.contains(normalizedExt) else { - throw RepositoryIconAssetStoreError.unsupportedExtension(normalizedExt) - } + // No extension whitelist — the file picker filters down to + // image UTTypes already, and anything that NSImage can't + // render later falls back to the dashed-questionmark + // placeholder in `RepositoryIconImage`. The `.svg` suffix + // remains the lone meaningful signal because it gates the + // template-tinting branch downstream; everything else is + // treated as an opaque bitmap. + let normalizedExt = + sourceURL.pathExtension.lowercased().isEmpty + ? "img" + : sourceURL.pathExtension.lowercased() let directory = SupacodePaths.repositoryIconsDirectory(for: rootURL) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let filename = "\(UUID().uuidString.lowercased()).\(normalizedExt)" diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 8f21ccde..7e4df9df 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -222,8 +222,6 @@ struct RepositorySettingsFeature { do { let filename = try store.importImage(sourceURL, rootURL) await send(.userImageImported(filename: filename)) - } catch let error as RepositoryIconAssetStoreError { - await send(.userImageImportFailed(Self.errorMessage(for: error))) } catch { await send(.userImageImportFailed(error.localizedDescription)) } @@ -326,10 +324,4 @@ struct RepositorySettingsFeature { } } - private static func errorMessage(for error: RepositoryIconAssetStoreError) -> String { - switch error { - case .unsupportedExtension(let ext): - return "Repository icons must be PNG or SVG. \(ext.uppercased()) files aren't supported." - } - } } diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index 56a02270..3f385721 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -39,15 +39,21 @@ struct RepositoryAppearancePickerView: View { title: "Repository Icon", subtitle: "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " - + "with the repo color; PNG keeps its own colors.", + + "with the repo color; bitmap formats keep their own colors.", presets: RepositoryIconPresets.presets, onApply: { applySymbolFromPicker($0) }, onCancel: { isSymbolPickerPresented = false } ) } + // Accept any image UTType — PNG / JPEG / WebP / HEIC / TIFF / GIF + // / etc. all flow through the same `NSImage(contentsOf:)` render + // path. SVG is listed explicitly because it's a structured-text + // format that doesn't always conform to `.image` in older + // UTType conformance tables. Anything that fails to decode falls + // back to the dashed placeholder at render time. .fileImporter( isPresented: $isImageImporterPresented, - allowedContentTypes: [.png, .svg], + allowedContentTypes: [.image, .svg], allowsMultipleSelection: false ) { result in handleImageImportResult(result) @@ -171,7 +177,7 @@ struct RepositoryAppearancePickerView: View { private var iconHelpText: String { switch store.appearance.icon { case .userImage(let filename) where !filename.lowercased().hasSuffix(".svg"): - return "PNG icons keep their original colors and ignore the repo color." + return "Bitmap icons keep their original colors and ignore the repo color." case .userImage: return "User-provided SVGs are tinted with the repo color." case .sfSymbol: diff --git a/supacodeTests/RepositoryIconAssetStoreTests.swift b/supacodeTests/RepositoryIconAssetStoreTests.swift index 8dccc4d4..ed7ada1f 100644 --- a/supacodeTests/RepositoryIconAssetStoreTests.swift +++ b/supacodeTests/RepositoryIconAssetStoreTests.swift @@ -80,17 +80,38 @@ struct RepositoryIconAssetStoreTests { #expect(filename.hasSuffix(".svg")) } - @Test func importImageRejectsUnsupportedExtension() throws { + @Test func importImageAcceptsArbitraryImageExtensions() throws { + // The store no longer enforces a PNG/SVG whitelist — the file + // picker filters down to image UTTypes already, and anything + // that NSImage can't decode falls back to a placeholder at + // render time. JPG / WebP / HEIC / GIF / TIFF / etc. all flow + // through the same byte-copy path and round-trip through + // `repositoryIconFileURL` like PNG does. let store = RepositoryIconAssetStore.liveValue let repoRoot = makeRepoRootScratch() - let source = ScratchDirectory(prefix: "prowl-icon-source") - let sourceFile = try writeSourceFile(in: source, extension: "jpeg") - #expect(throws: RepositoryIconAssetStoreError.self) { - _ = try store.importImage(sourceFile, repoRoot.url) + for ext in ["jpg", "jpeg", "webp", "heic", "gif", "tiff", "bmp"] { + let source = ScratchDirectory(prefix: "prowl-icon-source") + let sourceFile = try writeSourceFile(in: source, extension: ext) + let filename = try store.importImage(sourceFile, repoRoot.url) + #expect(filename.hasSuffix(".\(ext)")) } } + @Test func importImageHandlesFileWithNoExtension() throws { + // Defensive: a dragged-in file without an extension shouldn't + // crash the importer. The destination filename gets a generic + // fallback so the round-trip still works. + let store = RepositoryIconAssetStore.liveValue + let repoRoot = makeRepoRootScratch() + let source = ScratchDirectory(prefix: "prowl-icon-source") + let sourceFile = source.url.appending(path: "icon", directoryHint: .notDirectory) + try Data([0xDE, 0xAD]).write(to: sourceFile) + + let filename = try store.importImage(sourceFile, repoRoot.url) + #expect(!filename.isEmpty) + } + @Test func importImageCreatesIconsDirectoryWhenMissing() throws { let store = RepositoryIconAssetStore.liveValue let repoRoot = makeRepoRootScratch() diff --git a/supacodeTests/RepositorySettingsAppearanceTests.swift b/supacodeTests/RepositorySettingsAppearanceTests.swift index 6cb75f17..9d1998c4 100644 --- a/supacodeTests/RepositorySettingsAppearanceTests.swift +++ b/supacodeTests/RepositorySettingsAppearanceTests.swift @@ -179,24 +179,6 @@ struct RepositorySettingsAppearanceTests { await store.finish() } - @Test func importFailureSurfacesErrorMessage() async throws { - let store = makeStore( - iconAssetStore: RepositoryIconAssetStore( - importImage: { _, _ in throw RepositoryIconAssetStoreError.unsupportedExtension("jpeg") }, - remove: { _, _ in }, - exists: { _, _ in false } - ) - ) - - await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.jpeg"))) - await store.receive(\.userImageImportFailed) { - $0.appearanceImportError = """ - Repository icons must be PNG or SVG. JPEG files aren't supported. - """.trimmingCharacters(in: .whitespacesAndNewlines) - } - await store.finish() - } - @Test func importGenericErrorSurfacesLocalizedDescription() async throws { struct Boom: LocalizedError { var errorDescription: String? { "boom" } } let store = makeStore(