native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(repo-appearance): add data layer for per-repo icon and color

Introduce RepositoryAppearance — an optional icon + color pair persisted
in a global `~/.prowl/repository-appearances.json` keyed by Repository.ID
— so the sidebar, shelf spine, and canvas card can all read a single
`@Shared` dict at render time without per-repo file IO.

- RepositoryColorChoice: closed 10-color palette (Finder 7 + mint/cyan/pink)
resolved to system Colors only, encoded as raw case names for migration
safety.
- RepositoryIconSource: tagged enum (sfSymbol / bundledAsset / userImage)
encoded as a single marker-prefixed string, mirroring the TabIconSource
convention. PNG user images are explicitly non-tintable; SVGs are.
- RepositoryAppearancesKey: SharedKey backed by SettingsFileStorage with
empty-entry pruning so cleared rows don't accumulate dead JSON keys.
- RepositoryIconAssetStore: dependency-injected file gateway that imports
user-picked PNG/SVG into ~/.prowl/repo/<name>/icons/<uuid>.<ext>, so
removing the per-repo settings directory cleans the icons up too.
- SupacodePaths: add repositoryAppearancesURL, repositoryIconsDirectory,
and repositoryIconFileURL helpers.

Tests cover palette stability, storage-string round-trips for all three
icon sources, codable forward-compat, file IO golden paths, error
surfaces, and SharedKey load/save/empty-pruning semantics.

onevcat a68eeea4 cff366c5

+871
+85
supacode/Clients/Repositories/RepositoryAppearancesKey.swift
··· 1 + import Dependencies 2 + import Foundation 3 + import Sharing 4 + 5 + /// Persisted dictionary keyed by `Repository.ID` (the path-derived id 6 + /// from `RepositoryEntryNormalizer`) holding each repo's user-picked 7 + /// icon and color. One global file rather than per-repo so the sidebar 8 + /// — which renders every row — gets every appearance in a single 9 + /// `@Shared` read; per-repo settings would force one file load per 10 + /// row at startup. 11 + /// 12 + /// On-disk location: `~/.prowl/repository-appearances.json`. Repos 13 + /// without an entry behave exactly like before (no icon, accent color 14 + /// fallback) so the file is purely additive. 15 + nonisolated struct RepositoryAppearancesKeyID: Hashable, Sendable {} 16 + 17 + nonisolated enum RepositoryAppearancesFileURLKey: DependencyKey { 18 + static var liveValue: URL { SupacodePaths.repositoryAppearancesURL } 19 + static var previewValue: URL { SupacodePaths.repositoryAppearancesURL } 20 + static var testValue: URL { SupacodePaths.repositoryAppearancesURL } 21 + } 22 + 23 + extension DependencyValues { 24 + nonisolated var repositoryAppearancesFileURL: URL { 25 + get { self[RepositoryAppearancesFileURLKey.self] } 26 + set { self[RepositoryAppearancesFileURLKey.self] = newValue } 27 + } 28 + } 29 + 30 + nonisolated struct RepositoryAppearancesKey: SharedKey { 31 + var id: RepositoryAppearancesKeyID { 32 + RepositoryAppearancesKeyID() 33 + } 34 + 35 + func load( 36 + context _: LoadContext<[Repository.ID: RepositoryAppearance]>, 37 + continuation: LoadContinuation<[Repository.ID: RepositoryAppearance]> 38 + ) { 39 + @Dependency(\.settingsFileStorage) var storage 40 + @Dependency(\.repositoryAppearancesFileURL) var url 41 + let decoder = JSONDecoder() 42 + if let data = try? storage.load(url), 43 + let entries = try? decoder.decode([Repository.ID: RepositoryAppearance].self, from: data) 44 + { 45 + continuation.resume(returning: entries) 46 + return 47 + } 48 + continuation.resumeReturningInitialValue() 49 + } 50 + 51 + func subscribe( 52 + context _: LoadContext<[Repository.ID: RepositoryAppearance]>, 53 + subscriber _: SharedSubscriber<[Repository.ID: RepositoryAppearance]> 54 + ) -> SharedSubscription { 55 + SharedSubscription {} 56 + } 57 + 58 + func save( 59 + _ value: [Repository.ID: RepositoryAppearance], 60 + context _: SaveContext, 61 + continuation: SaveContinuation 62 + ) { 63 + @Dependency(\.settingsFileStorage) var storage 64 + @Dependency(\.repositoryAppearancesFileURL) var url 65 + let encoder = JSONEncoder() 66 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 67 + do { 68 + // Drop empty entries before writing so the file stays tight when 69 + // a user clears both icon and color — the absence of a key is 70 + // the canonical "no appearance" state. 71 + let pruned = value.filter { !$0.value.isEmpty } 72 + let data = try encoder.encode(pruned) 73 + try storage.save(data, url) 74 + continuation.resume() 75 + } catch { 76 + continuation.resume(throwing: error) 77 + } 78 + } 79 + } 80 + 81 + nonisolated extension SharedReaderKey where Self == RepositoryAppearancesKey.Default { 82 + static var repositoryAppearances: Self { 83 + Self[RepositoryAppearancesKey(), default: [:]] 84 + } 85 + }
+96
supacode/Clients/Repositories/RepositoryIconAssetStore.swift
··· 1 + import Dependencies 2 + import Foundation 3 + 4 + /// File-system gateway for user-imported repository icon images. Wraps 5 + /// the actual disk operations behind closures so both the live build 6 + /// (real `FileManager`) and tests (in-memory) can drive the same code 7 + /// paths without forking implementations. 8 + /// 9 + /// All filenames returned by the store are bare names (e.g. 10 + /// `"3F2D…ABC.svg"`) — never absolute paths — so the persisted 11 + /// `RepositoryAppearance.icon` stays portable: moving a repository 12 + /// directory takes its icons with it without rewriting JSON. 13 + nonisolated struct RepositoryIconAssetStore: Sendable { 14 + /// Imports a user-picked image into the per-repo icons directory and 15 + /// returns the bare filename to persist. The implementation chooses 16 + /// the filename (UUID + extension), creating intermediate directories 17 + /// as needed. 18 + var importImage: 19 + @Sendable ( 20 + _ sourceURL: URL, 21 + _ repositoryRootURL: URL 22 + ) throws -> String 23 + 24 + /// Removes a previously-imported image. No-op when the file is 25 + /// already gone (idempotent so reset/replace can call without 26 + /// guarding against stale state). 27 + var remove: 28 + @Sendable ( 29 + _ filename: String, 30 + _ repositoryRootURL: URL 31 + ) throws -> Void 32 + 33 + /// Returns whether a previously-stored filename still resolves to an 34 + /// existing file. Renderers use this to decide whether to fall back. 35 + var exists: 36 + @Sendable ( 37 + _ filename: String, 38 + _ repositoryRootURL: URL 39 + ) -> Bool 40 + } 41 + 42 + nonisolated enum RepositoryIconAssetStoreError: Error, Equatable { 43 + case unsupportedExtension(String) 44 + } 45 + 46 + nonisolated extension RepositoryIconAssetStore { 47 + /// Allowed input extensions. PNG and SVG only — JPEG and other 48 + /// formats either don't suit repo icon use (no transparency) or 49 + /// don't render well at small sidebar sizes. 50 + static let supportedExtensions: Set<String> = ["png", "svg"] 51 + 52 + static var liveValue: RepositoryIconAssetStore { 53 + RepositoryIconAssetStore( 54 + importImage: { sourceURL, rootURL in 55 + let normalizedExt = sourceURL.pathExtension.lowercased() 56 + guard Self.supportedExtensions.contains(normalizedExt) else { 57 + throw RepositoryIconAssetStoreError.unsupportedExtension(normalizedExt) 58 + } 59 + let directory = SupacodePaths.repositoryIconsDirectory(for: rootURL) 60 + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 61 + let filename = "\(UUID().uuidString.lowercased()).\(normalizedExt)" 62 + let destination = directory.appending(path: filename, directoryHint: .notDirectory) 63 + let data = try Data(contentsOf: sourceURL) 64 + try data.write(to: destination, options: [.atomic]) 65 + return filename 66 + }, 67 + remove: { filename, rootURL in 68 + let url = SupacodePaths.repositoryIconFileURL( 69 + filename: filename, repositoryRootURL: rootURL 70 + ) 71 + if FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) { 72 + try FileManager.default.removeItem(at: url) 73 + } 74 + }, 75 + exists: { filename, rootURL in 76 + let url = SupacodePaths.repositoryIconFileURL( 77 + filename: filename, repositoryRootURL: rootURL 78 + ) 79 + return FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) 80 + } 81 + ) 82 + } 83 + } 84 + 85 + nonisolated enum RepositoryIconAssetStoreKey: DependencyKey { 86 + static var liveValue: RepositoryIconAssetStore { .liveValue } 87 + static var previewValue: RepositoryIconAssetStore { .liveValue } 88 + static var testValue: RepositoryIconAssetStore { .liveValue } 89 + } 90 + 91 + extension DependencyValues { 92 + nonisolated var repositoryIconAssetStore: RepositoryIconAssetStore { 93 + get { self[RepositoryIconAssetStoreKey.self] } 94 + set { self[RepositoryIconAssetStoreKey.self] = newValue } 95 + } 96 + }
+27
supacode/Domain/RepositoryAppearance.swift
··· 1 + import Foundation 2 + 3 + /// User-pinned visual identity for a single repository: an optional 4 + /// icon source and an optional color choice, both freely combinable. 5 + /// Both fields are independently optional so a user can color-tag a 6 + /// repo without picking an icon (and vice versa). 7 + /// 8 + /// Persisted as part of a global `[Repository.ID: RepositoryAppearance]` 9 + /// dictionary — not nested in `Repository` or `RepositorySettings` — 10 + /// because the sidebar / shelf / canvas all need O(1) cross-repo 11 + /// lookups during render and a single `@Shared` dict is the lightest 12 + /// way to give every renderer the same view. 13 + nonisolated struct RepositoryAppearance: Codable, Equatable, Hashable, Sendable { 14 + var icon: RepositoryIconSource? 15 + var color: RepositoryColorChoice? 16 + 17 + static let empty = RepositoryAppearance(icon: nil, color: nil) 18 + 19 + init(icon: RepositoryIconSource? = nil, color: RepositoryColorChoice? = nil) { 20 + self.icon = icon 21 + self.color = color 22 + } 23 + 24 + var isEmpty: Bool { 25 + icon == nil && color == nil 26 + } 27 + }
+58
supacode/Domain/RepositoryColorChoice.swift
··· 1 + import SwiftUI 2 + 3 + /// One of a fixed palette of system-provided colors a user can pin to a 4 + /// repository to make it identifiable in the sidebar, shelf spine, and 5 + /// canvas card title bar. The palette is intentionally closed (10 colors) 6 + /// to align with macOS Finder's tag colors and to keep `repoColor` a 7 + /// purely semantic system color — never a custom hex — per the project's 8 + /// "system provided only" rule. 9 + /// 10 + /// Persistence: encoded as the raw `String` (case name). New cases are 11 + /// safe to append; cases must never be renamed once shipped because user 12 + /// JSON references them by name. 13 + nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, Hashable { 14 + case red 15 + case orange 16 + case yellow 17 + case green 18 + case mint 19 + case cyan 20 + case blue 21 + case purple 22 + case pink 23 + case gray 24 + 25 + /// User-facing label for the color picker. 26 + var displayName: String { 27 + switch self { 28 + case .red: "Red" 29 + case .orange: "Orange" 30 + case .yellow: "Yellow" 31 + case .green: "Green" 32 + case .mint: "Mint" 33 + case .cyan: "Cyan" 34 + case .blue: "Blue" 35 + case .purple: "Purple" 36 + case .pink: "Pink" 37 + case .gray: "Gray" 38 + } 39 + } 40 + 41 + /// Resolved SwiftUI color. Only the bare named system colors are used 42 + /// — never custom RGB — so the palette adapts to light/dark mode and 43 + /// any future system tweaks. 44 + var color: Color { 45 + switch self { 46 + case .red: .red 47 + case .orange: .orange 48 + case .yellow: .yellow 49 + case .green: .green 50 + case .mint: .mint 51 + case .cyan: .cyan 52 + case .blue: .blue 53 + case .purple: .purple 54 + case .pink: .pink 55 + case .gray: .gray 56 + } 57 + } 58 + }
+81
supacode/Domain/RepositoryIconSource.swift
··· 1 + import Foundation 2 + 3 + /// Where a repository's icon comes from. Storage is a single string so 4 + /// the on-disk JSON stays compact and migration-friendly; the marker 5 + /// convention mirrors `TabIconSource` / `ResolvedTabIcon` so a future 6 + /// reader looking at one knows the other. 7 + /// 8 + /// - `sfSymbol`: a system SF Symbol name; tintable. 9 + /// - `bundledAsset`: a name from the app's asset catalog (reserved for 10 + /// future branded presets; not user-importable). 11 + /// - `userImage`: a file the user dropped in via the picker, stored at 12 + /// `~/.prowl/repo/<name>/icons/<filename>`. Filename includes its 13 + /// extension so `isTintable` can distinguish PNG (no tint) from SVG. 14 + nonisolated enum RepositoryIconSource: Equatable, Hashable, Sendable { 15 + case sfSymbol(String) 16 + case bundledAsset(String) 17 + case userImage(filename: String) 18 + 19 + static let assetMarker = "@asset:" 20 + static let userImageMarker = "@file:" 21 + 22 + /// Round-tripped form for JSON storage. Bare strings stay SF Symbols 23 + /// for forward-compat with anything else that learns the convention. 24 + var storageString: String { 25 + switch self { 26 + case .sfSymbol(let name): 27 + name 28 + case .bundledAsset(let name): 29 + Self.assetMarker + name 30 + case .userImage(let filename): 31 + Self.userImageMarker + filename 32 + } 33 + } 34 + 35 + /// Inverse of `storageString`. Returns `nil` for empty input so 36 + /// callers can treat "no icon" and "blank string" identically. 37 + static func parse(_ raw: String) -> RepositoryIconSource? { 38 + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) 39 + guard !trimmed.isEmpty else { return nil } 40 + if trimmed.hasPrefix(userImageMarker) { 41 + return .userImage(filename: String(trimmed.dropFirst(userImageMarker.count))) 42 + } 43 + if trimmed.hasPrefix(assetMarker) { 44 + return .bundledAsset(String(trimmed.dropFirst(assetMarker.count))) 45 + } 46 + return .sfSymbol(trimmed) 47 + } 48 + 49 + /// PNG keeps its own colors; SF Symbols and SVGs are tintable. Bundled 50 + /// assets default to non-tintable so future additions don't repaint 51 + /// branded artwork unintentionally — flip per-asset if/when needed. 52 + var isTintable: Bool { 53 + switch self { 54 + case .sfSymbol: 55 + true 56 + case .bundledAsset: 57 + false 58 + case .userImage(let filename): 59 + filename.lowercased().hasSuffix(".svg") 60 + } 61 + } 62 + } 63 + 64 + extension RepositoryIconSource: Codable { 65 + init(from decoder: Decoder) throws { 66 + let container = try decoder.singleValueContainer() 67 + let raw = try container.decode(String.self) 68 + guard let parsed = Self.parse(raw) else { 69 + throw DecodingError.dataCorruptedError( 70 + in: container, 71 + debugDescription: "Empty repository icon storage string" 72 + ) 73 + } 74 + self = parsed 75 + } 76 + 77 + func encode(to encoder: Encoder) throws { 78 + var container = encoder.singleValueContainer() 79 + try container.encode(storageString) 80 + } 81 + }
+20
supacode/Support/SupacodePaths.swift
··· 113 113 baseDirectory.appending(path: "repository-entries.json", directoryHint: .notDirectory) 114 114 } 115 115 116 + static var repositoryAppearancesURL: URL { 117 + baseDirectory.appending(path: "repository-appearances.json", directoryHint: .notDirectory) 118 + } 119 + 120 + /// Directory where user-imported repository icon images live, scoped 121 + /// per-repo so cleanup is automatic when the per-repo settings 122 + /// directory is removed. 123 + static func repositoryIconsDirectory(for rootURL: URL) -> URL { 124 + repositorySettingsDirectory(for: rootURL) 125 + .appending(path: "icons", directoryHint: .isDirectory) 126 + } 127 + 128 + /// Resolved file URL for a stored icon filename. The filename is the 129 + /// only thing persisted in `RepositoryAppearance` so that moving a 130 + /// repository (or renaming its directory) leaves the artifact alone. 131 + static func repositoryIconFileURL(filename: String, repositoryRootURL rootURL: URL) -> URL { 132 + repositoryIconsDirectory(for: rootURL) 133 + .appending(path: filename, directoryHint: .notDirectory) 134 + } 135 + 116 136 static func migrateLegacyCacheFilesIfNeeded( 117 137 fileManager: FileManager = .default, 118 138 legacyDirectory: URL? = nil,
+74
supacodeTests/RepositoryAppearanceTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct RepositoryAppearanceTests { 7 + @Test func emptyHasBothNil() { 8 + #expect(RepositoryAppearance.empty.icon == nil) 9 + #expect(RepositoryAppearance.empty.color == nil) 10 + #expect(RepositoryAppearance.empty.isEmpty) 11 + } 12 + 13 + @Test func iconOnlyIsNotEmpty() { 14 + let appearance = RepositoryAppearance(icon: .sfSymbol("folder"), color: nil) 15 + #expect(!appearance.isEmpty) 16 + } 17 + 18 + @Test func colorOnlyIsNotEmpty() { 19 + let appearance = RepositoryAppearance(icon: nil, color: .blue) 20 + #expect(!appearance.isEmpty) 21 + } 22 + 23 + @Test func bothSetIsNotEmpty() { 24 + let appearance = RepositoryAppearance(icon: .sfSymbol("folder"), color: .blue) 25 + #expect(!appearance.isEmpty) 26 + } 27 + 28 + @Test func codableRoundTripWithBoth() throws { 29 + let original = RepositoryAppearance( 30 + icon: .sfSymbol("folder.fill"), 31 + color: .purple 32 + ) 33 + let data = try JSONEncoder().encode(original) 34 + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) 35 + #expect(decoded == original) 36 + } 37 + 38 + @Test func codableRoundTripIconOnly() throws { 39 + let original = RepositoryAppearance(icon: .userImage(filename: "abc.svg"), color: nil) 40 + let data = try JSONEncoder().encode(original) 41 + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) 42 + #expect(decoded == original) 43 + } 44 + 45 + @Test func codableRoundTripColorOnly() throws { 46 + let original = RepositoryAppearance(icon: nil, color: .green) 47 + let data = try JSONEncoder().encode(original) 48 + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) 49 + #expect(decoded == original) 50 + } 51 + 52 + @Test func codableRoundTripEmpty() throws { 53 + let data = try JSONEncoder().encode(RepositoryAppearance.empty) 54 + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) 55 + #expect(decoded == .empty) 56 + } 57 + 58 + @Test func decodingExtraFieldsIsTolerated() throws { 59 + // Forward-compat: a future schema may add fields; older builds 60 + // shouldn't refuse to decode. 61 + let raw = Data( 62 + """ 63 + { 64 + "icon": "folder", 65 + "color": "red", 66 + "futureField": "ignored" 67 + } 68 + """.utf8 69 + ) 70 + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: raw) 71 + #expect(decoded.icon == .sfSymbol("folder")) 72 + #expect(decoded.color == .red) 73 + } 74 + }
+124
supacodeTests/RepositoryAppearancesKeyTests.swift
··· 1 + import Dependencies 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Sharing 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct RepositoryAppearancesKeyTests { 10 + @Test(.dependencies) func loadReturnsEmptyDictionaryWhenFileMissing() { 11 + let storage = SettingsTestStorage() 12 + let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json") 13 + 14 + let appearances: [Repository.ID: RepositoryAppearance] = withDependencies { 15 + $0.settingsFileStorage = storage.storage 16 + $0.repositoryAppearancesFileURL = url 17 + } operation: { 18 + @Shared(.repositoryAppearances) var appearances 19 + return appearances 20 + } 21 + 22 + #expect(appearances.isEmpty) 23 + } 24 + 25 + @Test(.dependencies) func saveAndReloadRoundTrip() { 26 + let storage = SettingsTestStorage() 27 + let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json") 28 + 29 + withDependencies { 30 + $0.settingsFileStorage = storage.storage 31 + $0.repositoryAppearancesFileURL = url 32 + } operation: { 33 + @Shared(.repositoryAppearances) var appearances 34 + $appearances.withLock { 35 + $0["repo-1"] = RepositoryAppearance(icon: .sfSymbol("folder.fill"), color: .blue) 36 + $0["repo-2"] = RepositoryAppearance(icon: nil, color: .purple) 37 + } 38 + } 39 + 40 + let reloaded: [Repository.ID: RepositoryAppearance] = withDependencies { 41 + $0.settingsFileStorage = storage.storage 42 + $0.repositoryAppearancesFileURL = url 43 + } operation: { 44 + @Shared(.repositoryAppearances) var appearances 45 + return appearances 46 + } 47 + 48 + #expect( 49 + reloaded["repo-1"] == RepositoryAppearance(icon: .sfSymbol("folder.fill"), color: .blue) 50 + ) 51 + #expect(reloaded["repo-2"] == RepositoryAppearance(icon: nil, color: .purple)) 52 + } 53 + 54 + @Test(.dependencies) func saveDropsEmptyEntries() { 55 + // Clearing both icon and color resets a repo to the implicit 56 + // "no appearance" state — we don't want to leave dead `{}` entries 57 + // in the file forever. 58 + let storage = SettingsTestStorage() 59 + let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json") 60 + 61 + withDependencies { 62 + $0.settingsFileStorage = storage.storage 63 + $0.repositoryAppearancesFileURL = url 64 + } operation: { 65 + @Shared(.repositoryAppearances) var appearances 66 + $appearances.withLock { 67 + $0["keep"] = RepositoryAppearance(icon: .sfSymbol("folder"), color: nil) 68 + $0["drop"] = .empty 69 + } 70 + } 71 + 72 + let reloaded: [Repository.ID: RepositoryAppearance] = withDependencies { 73 + $0.settingsFileStorage = storage.storage 74 + $0.repositoryAppearancesFileURL = url 75 + } operation: { 76 + @Shared(.repositoryAppearances) var appearances 77 + return appearances 78 + } 79 + 80 + #expect(reloaded["keep"] != nil) 81 + #expect(reloaded["drop"] == nil) 82 + } 83 + 84 + @Test(.dependencies) func loadIgnoresCorruptFile() { 85 + let storage = SettingsTestStorage() 86 + let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json") 87 + try? storage.storage.save(Data("not-json".utf8), url) 88 + 89 + let appearances: [Repository.ID: RepositoryAppearance] = withDependencies { 90 + $0.settingsFileStorage = storage.storage 91 + $0.repositoryAppearancesFileURL = url 92 + } operation: { 93 + @Shared(.repositoryAppearances) var appearances 94 + return appearances 95 + } 96 + 97 + // Corrupt JSON should fall back to default (empty) rather than crash. 98 + #expect(appearances.isEmpty) 99 + } 100 + 101 + @Test(.dependencies) func savedJSONShape() throws { 102 + // The on-disk shape is part of the public surface — pin it so a 103 + // refactor of Codable defaults doesn't silently change the file 104 + // format users have on disk. 105 + let storage = SettingsTestStorage() 106 + let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json") 107 + 108 + withDependencies { 109 + $0.settingsFileStorage = storage.storage 110 + $0.repositoryAppearancesFileURL = url 111 + } operation: { 112 + @Shared(.repositoryAppearances) var appearances 113 + $appearances.withLock { 114 + $0["alpha"] = RepositoryAppearance(icon: .sfSymbol("folder"), color: .red) 115 + } 116 + } 117 + 118 + let data = try storage.storage.load(url) 119 + let json = try JSONSerialization.jsonObject(with: data) as? [String: [String: String]] 120 + let alpha = try #require(json?["alpha"]) 121 + #expect(alpha["icon"] == "folder") 122 + #expect(alpha["color"] == "red") 123 + } 124 + }
+50
supacodeTests/RepositoryColorChoiceTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct RepositoryColorChoiceTests { 7 + @Test func paletteHasTenSystemColors() { 8 + // The fixed palette is part of the persistence contract: once 9 + // shipped, removing or renaming a case would break user JSON. This 10 + // test pins the count so an accidental rename or removal trips a 11 + // failure before release. 12 + #expect(RepositoryColorChoice.allCases.count == 10) 13 + } 14 + 15 + @Test func paletteCasesAreStable() { 16 + // Raw values are written to JSON; reordering allCases is fine but 17 + // case names are forever. Pin them. 18 + let names = RepositoryColorChoice.allCases.map(\.rawValue).sorted() 19 + #expect( 20 + names == [ 21 + "blue", 22 + "cyan", 23 + "gray", 24 + "green", 25 + "mint", 26 + "orange", 27 + "pink", 28 + "purple", 29 + "red", 30 + "yellow", 31 + ] 32 + ) 33 + } 34 + 35 + @Test func codableRoundTrip() throws { 36 + let encoder = JSONEncoder() 37 + let decoder = JSONDecoder() 38 + for choice in RepositoryColorChoice.allCases { 39 + let data = try encoder.encode(choice) 40 + let decoded = try decoder.decode(RepositoryColorChoice.self, from: data) 41 + #expect(decoded == choice) 42 + } 43 + } 44 + 45 + @Test func displayNameNonEmpty() { 46 + for choice in RepositoryColorChoice.allCases { 47 + #expect(!choice.displayName.isEmpty) 48 + } 49 + } 50 + }
+139
supacodeTests/RepositoryIconAssetStoreTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct RepositoryIconAssetStoreTests { 8 + // MARK: - Helpers 9 + 10 + private static func makeTempRepoRoot() -> URL { 11 + let url = URL(fileURLWithPath: NSTemporaryDirectory()) 12 + .appending(path: "prowl-icon-store-\(UUID().uuidString)", directoryHint: .isDirectory) 13 + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) 14 + return url 15 + } 16 + 17 + private static func writeSourceFile(extension ext: String, contents: Data = Data([0xDE, 0xAD])) 18 + throws 19 + -> URL 20 + { 21 + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) 22 + .appending(path: "prowl-icon-source-\(UUID().uuidString)", directoryHint: .isDirectory) 23 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 24 + let url = dir.appending(path: "icon.\(ext)", directoryHint: .notDirectory) 25 + try contents.write(to: url) 26 + return url 27 + } 28 + 29 + // MARK: - importImage 30 + 31 + @Test func importImageCopiesFileWithUUIDName() throws { 32 + let store = RepositoryIconAssetStore.liveValue 33 + let repoRoot = Self.makeTempRepoRoot() 34 + let source = try Self.writeSourceFile(extension: "png", contents: Data([0x01, 0x02, 0x03])) 35 + 36 + let filename = try store.importImage(source, repoRoot) 37 + 38 + #expect(filename.hasSuffix(".png")) 39 + #expect(UUID(uuidString: String(filename.dropLast(4))) != nil) 40 + 41 + let resolved = SupacodePaths.repositoryIconFileURL( 42 + filename: filename, repositoryRootURL: repoRoot 43 + ) 44 + let copied = try Data(contentsOf: resolved) 45 + #expect(copied == Data([0x01, 0x02, 0x03])) 46 + } 47 + 48 + @Test func importImageNormalizesUppercaseExtension() throws { 49 + let store = RepositoryIconAssetStore.liveValue 50 + let repoRoot = Self.makeTempRepoRoot() 51 + let source = try Self.writeSourceFile(extension: "PNG") 52 + 53 + let filename = try store.importImage(source, repoRoot) 54 + #expect(filename.hasSuffix(".png")) 55 + } 56 + 57 + @Test func importImageAcceptsSVG() throws { 58 + let store = RepositoryIconAssetStore.liveValue 59 + let repoRoot = Self.makeTempRepoRoot() 60 + let source = try Self.writeSourceFile(extension: "svg") 61 + 62 + let filename = try store.importImage(source, repoRoot) 63 + #expect(filename.hasSuffix(".svg")) 64 + } 65 + 66 + @Test func importImageRejectsUnsupportedExtension() throws { 67 + let store = RepositoryIconAssetStore.liveValue 68 + let repoRoot = Self.makeTempRepoRoot() 69 + let source = try Self.writeSourceFile(extension: "jpeg") 70 + 71 + #expect(throws: RepositoryIconAssetStoreError.self) { 72 + _ = try store.importImage(source, repoRoot) 73 + } 74 + } 75 + 76 + @Test func importImageCreatesIconsDirectoryWhenMissing() throws { 77 + let store = RepositoryIconAssetStore.liveValue 78 + let repoRoot = Self.makeTempRepoRoot() 79 + // Don't pre-create the icons directory — importImage should make 80 + // it itself, otherwise first-time imports would fail. 81 + let source = try Self.writeSourceFile(extension: "png") 82 + 83 + _ = try store.importImage(source, repoRoot) 84 + 85 + let iconsDir = SupacodePaths.repositoryIconsDirectory(for: repoRoot) 86 + var isDirectory: ObjCBool = false 87 + let exists = FileManager.default.fileExists( 88 + atPath: iconsDir.path(percentEncoded: false), isDirectory: &isDirectory 89 + ) 90 + #expect(exists) 91 + #expect(isDirectory.boolValue) 92 + } 93 + 94 + @Test func importImageGeneratesUniqueFilenamePerCall() throws { 95 + let store = RepositoryIconAssetStore.liveValue 96 + let repoRoot = Self.makeTempRepoRoot() 97 + let source = try Self.writeSourceFile(extension: "png") 98 + 99 + let first = try store.importImage(source, repoRoot) 100 + let second = try store.importImage(source, repoRoot) 101 + #expect(first != second) 102 + } 103 + 104 + // MARK: - exists 105 + 106 + @Test func existsReportsFalseWhenMissing() { 107 + let store = RepositoryIconAssetStore.liveValue 108 + let repoRoot = Self.makeTempRepoRoot() 109 + #expect(!store.exists("nonexistent.png", repoRoot)) 110 + } 111 + 112 + @Test func existsReportsTrueAfterImport() throws { 113 + let store = RepositoryIconAssetStore.liveValue 114 + let repoRoot = Self.makeTempRepoRoot() 115 + let source = try Self.writeSourceFile(extension: "png") 116 + let filename = try store.importImage(source, repoRoot) 117 + #expect(store.exists(filename, repoRoot)) 118 + } 119 + 120 + // MARK: - remove 121 + 122 + @Test func removeDeletesImportedFile() throws { 123 + let store = RepositoryIconAssetStore.liveValue 124 + let repoRoot = Self.makeTempRepoRoot() 125 + let source = try Self.writeSourceFile(extension: "png") 126 + let filename = try store.importImage(source, repoRoot) 127 + 128 + try store.remove(filename, repoRoot) 129 + #expect(!store.exists(filename, repoRoot)) 130 + } 131 + 132 + @Test func removeIsIdempotent() throws { 133 + // Reset / replace flows can call remove repeatedly; missing files 134 + // shouldn't throw or the reducer would have to track existence. 135 + let store = RepositoryIconAssetStore.liveValue 136 + let repoRoot = Self.makeTempRepoRoot() 137 + try store.remove("never-existed.png", repoRoot) 138 + } 139 + }
+117
supacodeTests/RepositoryIconSourceTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct RepositoryIconSourceTests { 7 + // MARK: - storageString encoding 8 + 9 + @Test func sfSymbolSerialisesBare() { 10 + let icon = RepositoryIconSource.sfSymbol("folder.fill") 11 + #expect(icon.storageString == "folder.fill") 12 + } 13 + 14 + @Test func bundledAssetUsesAssetMarker() { 15 + let icon = RepositoryIconSource.bundledAsset("Docker") 16 + #expect(icon.storageString == "@asset:Docker") 17 + } 18 + 19 + @Test func userImageUsesFileMarker() { 20 + let icon = RepositoryIconSource.userImage(filename: "abc.png") 21 + #expect(icon.storageString == "@file:abc.png") 22 + } 23 + 24 + // MARK: - parse 25 + 26 + @Test func parseEmptyReturnsNil() { 27 + #expect(RepositoryIconSource.parse("") == nil) 28 + #expect(RepositoryIconSource.parse(" ") == nil) 29 + } 30 + 31 + @Test func parseBareStringIsSFSymbol() { 32 + #expect(RepositoryIconSource.parse("folder") == .sfSymbol("folder")) 33 + } 34 + 35 + @Test func parseAssetMarker() { 36 + #expect(RepositoryIconSource.parse("@asset:Docker") == .bundledAsset("Docker")) 37 + } 38 + 39 + @Test func parseFileMarker() { 40 + #expect(RepositoryIconSource.parse("@file:abc.svg") == .userImage(filename: "abc.svg")) 41 + } 42 + 43 + @Test func parseTrimsWhitespace() { 44 + #expect(RepositoryIconSource.parse(" folder.fill ") == .sfSymbol("folder.fill")) 45 + } 46 + 47 + @Test func parsePreservesFilenameWithDots() { 48 + // Filenames carry the extension; the parser must not strip dots. 49 + let icon = RepositoryIconSource.parse("@file:logo.repo.svg") 50 + #expect(icon == .userImage(filename: "logo.repo.svg")) 51 + } 52 + 53 + // MARK: - Round-trip 54 + 55 + @Test func sfSymbolRoundTrip() { 56 + let source = RepositoryIconSource.sfSymbol("hammer") 57 + #expect(RepositoryIconSource.parse(source.storageString) == source) 58 + } 59 + 60 + @Test func bundledAssetRoundTrip() { 61 + let source = RepositoryIconSource.bundledAsset("Visual Studio Code") 62 + #expect(RepositoryIconSource.parse(source.storageString) == source) 63 + } 64 + 65 + @Test func userImageRoundTrip() { 66 + let source = RepositoryIconSource.userImage(filename: "abc-123.png") 67 + #expect(RepositoryIconSource.parse(source.storageString) == source) 68 + } 69 + 70 + // MARK: - Codable (single-value String) 71 + 72 + @Test func encodesAsSingleString() throws { 73 + let icon = RepositoryIconSource.sfSymbol("folder.fill") 74 + let data = try JSONEncoder().encode(icon) 75 + let decoded = try JSONDecoder().decode(String.self, from: data) 76 + #expect(decoded == "folder.fill") 77 + } 78 + 79 + @Test func decodesFromSingleString() throws { 80 + let raw = Data("\"@file:abc.png\"".utf8) 81 + let decoded = try JSONDecoder().decode(RepositoryIconSource.self, from: raw) 82 + #expect(decoded == .userImage(filename: "abc.png")) 83 + } 84 + 85 + @Test func decodingEmptyStringFails() { 86 + let raw = Data("\"\"".utf8) 87 + #expect(throws: DecodingError.self) { 88 + try JSONDecoder().decode(RepositoryIconSource.self, from: raw) 89 + } 90 + } 91 + 92 + // MARK: - isTintable 93 + 94 + @Test func sfSymbolIsTintable() { 95 + #expect(RepositoryIconSource.sfSymbol("folder").isTintable) 96 + } 97 + 98 + @Test func bundledAssetIsNotTintable() { 99 + #expect(!RepositoryIconSource.bundledAsset("Docker").isTintable) 100 + } 101 + 102 + @Test func pngUserImageIsNotTintable() { 103 + #expect(!RepositoryIconSource.userImage(filename: "abc.png").isTintable) 104 + } 105 + 106 + @Test func pngUserImageWithUppercaseExtensionIsNotTintable() { 107 + #expect(!RepositoryIconSource.userImage(filename: "abc.PNG").isTintable) 108 + } 109 + 110 + @Test func svgUserImageIsTintable() { 111 + #expect(RepositoryIconSource.userImage(filename: "abc.svg").isTintable) 112 + } 113 + 114 + @Test func svgUserImageWithUppercaseExtensionIsTintable() { 115 + #expect(RepositoryIconSource.userImage(filename: "abc.SVG").isTintable) 116 + } 117 + }