native macOS codings agent orchestrator
5
fork

Configure Feed

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

Merge pull request #240 from onevcat/feat/repo-icon-color

Add per-repo icon and color identity (sidebar / shelf / canvas)

authored by

Wei Wang and committed by
GitHub
3d19de23 674ec41e

+2244 -23
+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 + }
+94
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 extension RepositoryIconAssetStore { 43 + static var liveValue: RepositoryIconAssetStore { 44 + RepositoryIconAssetStore( 45 + importImage: { sourceURL, rootURL in 46 + // No extension whitelist — the file picker filters down to 47 + // image UTTypes already, and anything that NSImage can't 48 + // render later falls back to the dashed-questionmark 49 + // placeholder in `RepositoryIconImage`. The `.svg` suffix 50 + // remains the lone meaningful signal because it gates the 51 + // template-tinting branch downstream; everything else is 52 + // treated as an opaque bitmap. 53 + let normalizedExt = 54 + sourceURL.pathExtension.lowercased().isEmpty 55 + ? "img" 56 + : sourceURL.pathExtension.lowercased() 57 + let directory = SupacodePaths.repositoryIconsDirectory(for: rootURL) 58 + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 59 + let filename = "\(UUID().uuidString.lowercased()).\(normalizedExt)" 60 + let destination = directory.appending(path: filename, directoryHint: .notDirectory) 61 + let data = try Data(contentsOf: sourceURL) 62 + try data.write(to: destination, options: [.atomic]) 63 + return filename 64 + }, 65 + remove: { filename, rootURL in 66 + let url = SupacodePaths.repositoryIconFileURL( 67 + filename: filename, repositoryRootURL: rootURL 68 + ) 69 + if FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) { 70 + try FileManager.default.removeItem(at: url) 71 + } 72 + }, 73 + exists: { filename, rootURL in 74 + let url = SupacodePaths.repositoryIconFileURL( 75 + filename: filename, repositoryRootURL: rootURL 76 + ) 77 + return FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) 78 + } 79 + ) 80 + } 81 + } 82 + 83 + nonisolated enum RepositoryIconAssetStoreKey: DependencyKey { 84 + static var liveValue: RepositoryIconAssetStore { .liveValue } 85 + static var previewValue: RepositoryIconAssetStore { .liveValue } 86 + static var testValue: RepositoryIconAssetStore { .liveValue } 87 + } 88 + 89 + extension DependencyValues { 90 + nonisolated var repositoryIconAssetStore: RepositoryIconAssetStore { 91 + get { self[RepositoryIconAssetStoreKey.self] } 92 + set { self[RepositoryIconAssetStoreKey.self] = newValue } 93 + } 94 + }
+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 + }
+71
supacode/Domain/RepositoryIconPresets.swift
··· 1 + import Foundation 2 + 3 + /// Repository-flavored SF Symbol picker presets. These are surfaced by 4 + /// `RepositoryAppearancePickerView` (which reuses `TabIconPickerView` 5 + /// with this list), distinct from the terminal-themed list the tab 6 + /// picker ships. The split exists because the same picker is used in 7 + /// two contexts that reach for different vocabulary: a tab picker 8 + /// favors `play.fill` / `terminal` / `ladybug.fill`, a repo picker 9 + /// favors `folder.fill` / `book.fill` / `hammer.fill`. 10 + /// 11 + /// Order is loosely thematic so scanning the grid surfaces intent: 12 + /// folders → boxes / data → docs / books → tools / dev → network → 13 + /// art → nature / vibes. 14 + /// 15 + /// All entries are restricted to SF Symbols 1–2 (macOS 11–12) baseline 16 + /// so they're guaranteed-available on the project's macOS 26 minimum. 17 + /// `.fill` variants are only listed when the symbol actually has one 18 + /// (e.g. `rocket.fill` was tried but doesn't exist — only `rocket` 19 + /// does, which renders as a question-mark placeholder if mistakenly 20 + /// suffixed). 21 + nonisolated enum RepositoryIconPresets { 22 + static let presets: [String] = [ 23 + // Folders / containers (8) 24 + "folder.fill", 25 + "folder", 26 + "folder.badge.plus", 27 + "tray.fill", 28 + "tray.full.fill", 29 + "shippingbox.fill", 30 + "archivebox.fill", 31 + "externaldrive.fill", 32 + // Docs / books (6) 33 + "doc.fill", 34 + "doc.text.fill", 35 + "doc.richtext.fill", 36 + "book.fill", 37 + "books.vertical.fill", 38 + "bookmark.fill", 39 + // Tags / markers (3) 40 + "tag.fill", 41 + "flag.fill", 42 + "paperplane.fill", 43 + // Tools / dev (8) 44 + "hammer.fill", 45 + "wrench.fill", 46 + "wrench.and.screwdriver.fill", 47 + "screwdriver.fill", 48 + "gearshape.fill", 49 + "gear", 50 + "cpu", 51 + "ladybug.fill", 52 + // Network / web (4) 53 + "globe", 54 + "network", 55 + "server.rack", 56 + "cloud.fill", 57 + // Art (2) 58 + "paintpalette.fill", 59 + "paintbrush.fill", 60 + // Nature / symbols (9) 61 + "star.fill", 62 + "heart.fill", 63 + "leaf.fill", 64 + "bolt.fill", 65 + "sparkles", 66 + "flame.fill", 67 + "sun.max.fill", 68 + "moon.fill", 69 + "envelope.fill", 70 + ] 71 + }
+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 + }
+4 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 391 391 } 392 392 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 393 393 @Shared(.userRepositorySettings(repository.rootURL)) var userRepositorySettings 394 + @Shared(.repositoryAppearances) var repositoryAppearances 394 395 var repoSettingsState = RepositorySettingsFeature.State( 395 396 rootURL: repository.rootURL, 397 + repositoryID: repository.id, 396 398 repositoryKind: repository.kind, 397 399 settings: repositorySettings, 398 - userSettings: userRepositorySettings 400 + userSettings: userRepositorySettings, 401 + appearance: repositoryAppearances[repository.id] ?? .empty 399 402 ) 400 403 repoSettingsState.globalCopyIgnoredOnWorktreeCreate = state.settings.copyIgnoredOnWorktreeCreate 401 404 repoSettingsState.globalCopyUntrackedOnWorktreeCreate = state.settings.copyUntrackedOnWorktreeCreate
+36 -3
supacode/Features/Canvas/Views/CanvasCardView.swift
··· 4 4 struct CanvasCardView: View { 5 5 let repositoryName: String 6 6 let worktreeName: String 7 + /// User-pinned icon for this card's repository, drawn before the 8 + /// repo name in the title bar. `nil` keeps the historical text-only 9 + /// title bar. 10 + var repositoryIcon: RepositoryIconSource? 11 + /// User-pinned color for this card's repository. When set, it tints 12 + /// both the icon (if tintable) and the title-bar background as an 13 + /// always-on identity strip. 14 + var repositoryColor: Color? 15 + /// Repo root URL, needed by `RepositoryIconImage` to resolve user 16 + /// PNG/SVG filenames against the per-repo icons directory. 17 + var repositoryRootURL: URL? 7 18 let tree: SplitTree<GhosttySurfaceView> 8 19 let isFocused: Bool 9 20 let isSelected: Bool ··· 109 120 110 121 private var titleBar: some View { 111 122 HStack(spacing: 6) { 123 + if let repositoryIcon, let repositoryRootURL { 124 + RepositoryIconImage( 125 + icon: repositoryIcon, 126 + repositoryRootURL: repositoryRootURL, 127 + tintColor: repositoryColor, 128 + size: 12 129 + ) 130 + } 112 131 Text(repositoryName) 113 132 .font(.caption.bold()) 114 133 .lineLimit(1) ··· 178 197 179 198 @ViewBuilder 180 199 private var titleBarBackground: some View { 200 + // Layering, back-to-front: 201 + // 202 + // 1. selected-but-unfocused accent (subtle, sits under the bar 203 + // material — same behavior as before this feature shipped) 204 + // 2. `.bar` material substrate 205 + // 3. **either** the notification orange **or** the repo color 206 + // identity strip — never both. Without this mutual exclusion 207 + // the orange used to muddle into the repo color (e.g. a blue 208 + // repo's notification looked brownish-grey instead of the 209 + // intended attention-grabbing orange). The notification wins 210 + // on top with a much higher alpha (0.55) than the previous 211 + // under-bar 0.3 so the unread signal actually pops. 181 212 ZStack { 182 - if hasUnseenNotification { 183 - Color.orange.opacity(0.3) 184 - } 185 213 if isSelected && !isFocused { 186 214 Color.accentColor.opacity(0.12) 187 215 } 188 216 Rectangle() 189 217 .fill(.bar) 190 218 .opacity(0.9) 219 + if hasUnseenNotification { 220 + Color.orange.opacity(0.55) 221 + } else if let repositoryColor { 222 + repositoryColor.opacity(isFocused ? 0.18 : 0.10) 223 + } 191 224 } 192 225 } 193 226
+19
supacode/Features/Canvas/Views/CanvasView.swift
··· 1 1 import AppKit 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct CanvasView: View { ··· 8 9 let terminalManager: WorktreeTerminalManager 9 10 var onExitToTab: () -> Void = {} 10 11 @State private var layoutStore = CanvasLayoutStore() 12 + @Shared(.repositoryAppearances) private var repositoryAppearances 11 13 12 14 @State private var canvasOffset: CGSize = .zero 13 15 @State private var lastCanvasOffset: CGSize = .zero ··· 82 84 let screenCenter = screenPosition(for: resized.center) 83 85 let cardTotalHeight = resized.size.height + titleBarHeight 84 86 87 + let repositoryAppearance = appearance(for: state.repositoryRootURL) 85 88 CanvasCardView( 86 89 repositoryName: Repository.name(for: state.repositoryRootURL), 87 90 worktreeName: tab.title, 91 + repositoryIcon: repositoryAppearance.icon, 92 + repositoryColor: repositoryAppearance.color?.color, 93 + repositoryRootURL: state.repositoryRootURL, 88 94 tree: tree, 89 95 isFocused: selectionState.primaryTabID == tab.id, 90 96 isSelected: selectionState.selectedTabIDs.contains(tab.id), ··· 766 772 // WorktreeTerminalTabsView.onAppear's syncFocus() and cause blank 767 773 // surfaces. Cleanup of non-selected worktrees is handled by 768 774 // setSelectedWorktreeID in the async exit flow. 775 + } 776 + 777 + /// Looks up the user-pinned `RepositoryAppearance` for a given repo 778 + /// root URL by deriving the canonical `Repository.ID` (the 779 + /// path-policy-normalized path string) and querying the @Shared 780 + /// dict. Returns `.empty` when no entry exists, which keeps cards 781 + /// visually identical to before the appearance feature shipped. 782 + private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { 783 + let id = 784 + PathPolicy.normalizePath( 785 + repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true 786 + ) ?? repositoryRootURL.path(percentEncoded: false) 787 + return repositoryAppearances[id] ?? .empty 769 788 } 770 789 } 771 790
+42 -3
supacode/Features/Repositories/Views/RepoHeaderRow.swift
··· 5 5 let name: String 6 6 let isRemoving: Bool 7 7 let tabCount: Int 8 + /// User-pinned icon, when set. Renders before the repo name. 9 + /// `nil` keeps the historical text-only layout intact. 10 + let icon: RepositoryIconSource? 11 + /// Resolved tint applied to tintable icons (SF Symbols / SVGs). 12 + /// PNGs and bundled assets ignore this and render their own colors. 13 + let iconTint: Color? 14 + /// Repo root URL — needed by `RepositoryIconImage` to resolve 15 + /// user-imported image filenames into absolute file URLs. 16 + let repositoryRootURL: URL? 8 17 var nameTooltip: String? 18 + 9 19 var body: some View { 10 20 HStack { 21 + if let icon, let repositoryRootURL { 22 + RepositoryIconImage( 23 + icon: icon, 24 + repositoryRootURL: repositoryRootURL, 25 + tintColor: iconTint, 26 + size: 14 27 + ) 28 + } 11 29 Text(name) 12 30 .foregroundStyle(.secondary) 13 31 .help(nameTooltip ?? "") ··· 44 62 45 63 #Preview("RepoHeaderRow") { 46 64 VStack(alignment: .leading, spacing: 12) { 47 - RepoHeaderRow(name: "supacode", isRemoving: false, tabCount: 3) 48 - RepoHeaderRow(name: "ghostty", isRemoving: false, tabCount: 0) 49 - RepoHeaderRow(name: "removing-repo", isRemoving: true, tabCount: 1) 65 + RepoHeaderRow( 66 + name: "supacode", 67 + isRemoving: false, 68 + tabCount: 3, 69 + icon: nil, 70 + iconTint: nil, 71 + repositoryRootURL: nil 72 + ) 73 + RepoHeaderRow( 74 + name: "ghostty", 75 + isRemoving: false, 76 + tabCount: 0, 77 + icon: .sfSymbol("folder.fill"), 78 + iconTint: .blue, 79 + repositoryRootURL: URL(fileURLWithPath: "/tmp/ghostty") 80 + ) 81 + RepoHeaderRow( 82 + name: "removing-repo", 83 + isRemoving: true, 84 + tabCount: 1, 85 + icon: .sfSymbol("hammer.fill"), 86 + iconTint: .orange, 87 + repositoryRootURL: URL(fileURLWithPath: "/tmp/removing") 88 + ) 50 89 } 51 90 .padding() 52 91 }
+108
supacode/Features/Repositories/Views/RepositoryIconImage.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + /// Single source of truth for rendering a `RepositoryIconSource` — 5 + /// shared by the settings preview, the sidebar row, the shelf spine 6 + /// header, and the canvas card title bar so tinting / fallback rules 7 + /// stay consistent in one place. 8 + /// 9 + /// Tinting follows `RepositoryIconSource.isTintable`: SF Symbols and 10 + /// SVG user images pick up `tintColor`; PNG user images and bundled 11 + /// assets ignore it. Missing user images fall back to a neutral SF 12 + /// Symbol so a deleted file on disk doesn't turn into a blank slot. 13 + struct RepositoryIconImage: View { 14 + let icon: RepositoryIconSource 15 + let repositoryRootURL: URL 16 + /// Color used for tintable artwork. Pass `nil` to keep the 17 + /// renderer's natural foreground (`.primary` / template default). 18 + let tintColor: Color? 19 + /// Logical pixel size of the icon. Affects `Image` sizing for asset 20 + /// and user-image cases; SF Symbols size off the surrounding font. 21 + let size: CGFloat 22 + 23 + init( 24 + icon: RepositoryIconSource, 25 + repositoryRootURL: URL, 26 + tintColor: Color? = nil, 27 + size: CGFloat = 16 28 + ) { 29 + self.icon = icon 30 + self.repositoryRootURL = repositoryRootURL 31 + self.tintColor = tintColor 32 + self.size = size 33 + } 34 + 35 + var body: some View { 36 + content 37 + .frame(width: size, height: size) 38 + .accessibilityHidden(true) 39 + } 40 + 41 + @ViewBuilder 42 + private var content: some View { 43 + switch icon { 44 + case .sfSymbol(let name): 45 + Image(systemName: name) 46 + .resizable() 47 + .aspectRatio(contentMode: .fit) 48 + .symbolRenderingMode(.monochrome) 49 + .foregroundStyle(resolvedTint) 50 + .accessibilityHidden(true) 51 + case .bundledAsset(let assetName): 52 + Image(assetName) 53 + .resizable() 54 + .aspectRatio(contentMode: .fit) 55 + .accessibilityHidden(true) 56 + case .userImage(let filename): 57 + userImage(filename: filename) 58 + } 59 + } 60 + 61 + @ViewBuilder 62 + private func userImage(filename: String) -> some View { 63 + let url = SupacodePaths.repositoryIconFileURL( 64 + filename: filename, repositoryRootURL: repositoryRootURL 65 + ) 66 + if let nsImage = Self.loadImage(at: url, asTemplate: icon.isTintable) { 67 + if icon.isTintable { 68 + Image(nsImage: nsImage) 69 + .resizable() 70 + .aspectRatio(contentMode: .fit) 71 + .foregroundStyle(resolvedTint) 72 + .accessibilityHidden(true) 73 + } else { 74 + Image(nsImage: nsImage) 75 + .resizable() 76 + .aspectRatio(contentMode: .fit) 77 + .accessibilityHidden(true) 78 + } 79 + } else { 80 + // The icon file was renamed/deleted out from under us. Show a 81 + // muted placeholder rather than an empty rect so the bug is 82 + // visible. 83 + Image(systemName: "questionmark.square.dashed") 84 + .resizable() 85 + .aspectRatio(contentMode: .fit) 86 + .foregroundStyle(.tertiary) 87 + .accessibilityHidden(true) 88 + } 89 + } 90 + 91 + /// Loads an NSImage from disk and optionally flips it into template 92 + /// mode so SwiftUI's `.foregroundStyle` can recolor it. Pulled out 93 + /// of the ViewBuilder body so the side-effecting assignment doesn't 94 + /// trip the builder's "type '()' cannot conform to 'View'" check. 95 + private static func loadImage(at url: URL, asTemplate: Bool) -> NSImage? { 96 + guard let image = NSImage(contentsOf: url) else { return nil } 97 + image.isTemplate = asTemplate 98 + return image 99 + } 100 + 101 + private var resolvedTint: AnyShapeStyle { 102 + if let tintColor { 103 + AnyShapeStyle(tintColor) 104 + } else { 105 + AnyShapeStyle(.primary) 106 + } 107 + } 108 + }
+13
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 1 1 import ComposableArchitecture 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct RepositorySectionView: View { ··· 14 15 @Environment(\.colorScheme) private var colorScheme 15 16 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 16 17 @State private var isHovering = false 18 + @Shared(.repositoryAppearances) private var repositoryAppearances 17 19 18 20 var body: some View { 19 21 let state = store.state ··· 34 36 } 35 37 let isDragging = isDragActive 36 38 39 + let appearance = repositoryAppearances[repository.id] ?? .empty 37 40 let header = HStack { 38 41 RepoHeaderRow( 39 42 name: repository.name, ··· 42 45 for: repository, 43 46 terminalManager: terminalManager 44 47 ), 48 + icon: appearance.icon, 49 + iconTint: appearance.color?.color, 50 + repositoryRootURL: repository.rootURL, 45 51 nameTooltip: repository.capabilities.supportsWorktrees 46 52 ? (isExpanded ? "Collapse" : "Expand") 47 53 : "Open terminal in folder" ··· 70 76 } 71 77 } 72 78 } 79 + } 80 + if let color = appearance.color { 81 + Circle() 82 + .fill(color.color) 83 + .frame(width: 8, height: 8) 84 + .help(color.displayName) 85 + .accessibilityLabel(Text("Repo color: \(color.displayName)")) 73 86 } 74 87 if isHovering && !isDragging { 75 88 Menu {
+110
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 6 6 @ObservableState 7 7 struct State: Equatable { 8 8 var rootURL: URL 9 + /// Persistence key for `@Shared(.repositoryAppearances)`. Defaults 10 + /// to empty so legacy test fixtures that only construct the four 11 + /// originally-required fields keep compiling — production 12 + /// callers (AppFeature) always pass the canonical `repository.id`. 13 + var repositoryID: Repository.ID = "" 9 14 var repositoryKind: Repository.Kind 10 15 var settings: RepositorySettings 11 16 var userSettings: UserRepositorySettings 17 + var appearance: RepositoryAppearance = .empty 12 18 var globalDefaultWorktreeBaseDirectoryPath: String? 13 19 var globalCopyIgnoredOnWorktreeCreate: Bool = false 14 20 var globalCopyUntrackedOnWorktreeCreate: Bool = false ··· 18 24 var defaultWorktreeBaseRef = "origin/main" 19 25 var isBranchDataLoaded = false 20 26 var keybindingUserOverrides: KeybindingUserOverrideStore = .empty 27 + var appearanceImportError: String? 21 28 22 29 var capabilities: Repository.Capabilities { 23 30 switch repositoryKind { ··· 73 80 globalPullRequestMergeStrategy: PullRequestMergeStrategy, 74 81 keybindingUserOverrides: KeybindingUserOverrideStore 75 82 ) 83 + case appearanceLoaded(RepositoryAppearance) 84 + case setAppearanceColor(RepositoryColorChoice?) 85 + case setAppearanceIcon(RepositoryIconSource?) 86 + case importUserImage(URL) 87 + case userImageImported(filename: String) 88 + case userImageImportFailed(String) 89 + case dismissAppearanceImportError 90 + case resetAppearance 76 91 case branchDataLoaded([String], defaultBaseRef: String) 77 92 case delegate(Delegate) 78 93 case binding(BindingAction<State>) ··· 84 99 } 85 100 86 101 @Dependency(GitClientDependency.self) private var gitClient 102 + @Dependency(\.repositoryIconAssetStore) private var repositoryIconAssetStore 87 103 88 104 var body: some Reducer<State, Action> { 89 105 BindingReducer() ··· 178 194 $repositorySettings.withLock { $0 = updatedSettings } 179 195 return .send(.delegate(.settingsChanged(rootURL))) 180 196 197 + case .appearanceLoaded(let appearance): 198 + state.appearance = appearance 199 + return .none 200 + 201 + case .setAppearanceColor(let color): 202 + guard state.appearance.color != color else { return .none } 203 + state.appearance.color = color 204 + return persistAppearance(state.appearance, repositoryID: state.repositoryID) 205 + 206 + case .setAppearanceIcon(let newIcon): 207 + let previousIcon = state.appearance.icon 208 + guard previousIcon != newIcon else { return .none } 209 + state.appearance.icon = newIcon 210 + let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) 211 + let cleanup = removeAbandonedUserImage( 212 + previous: previousIcon, 213 + new: newIcon, 214 + rootURL: state.rootURL 215 + ) 216 + return .merge(persist, cleanup) 217 + 218 + case .importUserImage(let sourceURL): 219 + let rootURL = state.rootURL 220 + let store = repositoryIconAssetStore 221 + return .run { send in 222 + do { 223 + let filename = try store.importImage(sourceURL, rootURL) 224 + await send(.userImageImported(filename: filename)) 225 + } catch { 226 + await send(.userImageImportFailed(error.localizedDescription)) 227 + } 228 + } 229 + 230 + case .userImageImported(let filename): 231 + return .send(.setAppearanceIcon(.userImage(filename: filename))) 232 + 233 + case .userImageImportFailed(let message): 234 + state.appearanceImportError = message 235 + return .none 236 + 237 + case .dismissAppearanceImportError: 238 + state.appearanceImportError = nil 239 + return .none 240 + 241 + case .resetAppearance: 242 + let previousIcon = state.appearance.icon 243 + guard !state.appearance.isEmpty else { return .none } 244 + state.appearance = .empty 245 + let persist = persistAppearance(.empty, repositoryID: state.repositoryID) 246 + let cleanup = removeAbandonedUserImage( 247 + previous: previousIcon, 248 + new: nil, 249 + rootURL: state.rootURL 250 + ) 251 + return .merge(persist, cleanup) 252 + 181 253 case .branchDataLoaded(let branches, let defaultBaseRef): 182 254 state.defaultWorktreeBaseRef = defaultBaseRef 183 255 var options = branches ··· 214 286 } 215 287 } 216 288 } 289 + 290 + /// Writes the appearance back to the global `@Shared` dict, dropping 291 + /// the entry when it's been cleared so the on-disk file stays tight. 292 + private func persistAppearance( 293 + _ appearance: RepositoryAppearance, 294 + repositoryID: Repository.ID 295 + ) -> Effect<Action> { 296 + .run { _ in 297 + @Shared(.repositoryAppearances) var appearances 298 + $appearances.withLock { 299 + if appearance.isEmpty { 300 + $0.removeValue(forKey: repositoryID) 301 + } else { 302 + $0[repositoryID] = appearance 303 + } 304 + } 305 + } 306 + } 307 + 308 + /// When the icon transitions away from a user-imported file, the old 309 + /// asset on disk is no longer referenced and should be cleaned up. 310 + /// No-op when the previous icon wasn't a user image or when the new 311 + /// icon is the same user image. 312 + private func removeAbandonedUserImage( 313 + previous: RepositoryIconSource?, 314 + new: RepositoryIconSource?, 315 + rootURL: URL 316 + ) -> Effect<Action> { 317 + guard case .userImage(let oldFilename) = previous else { return .none } 318 + if case .userImage(let newFilename) = new, newFilename == oldFilename { 319 + return .none 320 + } 321 + let store = repositoryIconAssetStore 322 + return .run { _ in 323 + try? store.remove(oldFilename, rootURL) 324 + } 325 + } 326 + 217 327 }
+359
supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import SwiftUI 4 + import UniformTypeIdentifiers 5 + 6 + /// Inline section that drives a repository's icon and color choice. 7 + /// Hosted at the top of `RepositorySettingsView`'s Form. The actual SF 8 + /// Symbol picker is presented as a sheet via `TabIconPickerView`, 9 + /// parameterised with `RepositoryIconPresets.presets` so the shared 10 + /// picker code surfaces repo-flavored vocabulary instead of the 11 + /// terminal one used by tab icons. 12 + /// 13 + /// All mutations go through `RepositorySettingsFeature` actions — 14 + /// never via direct `store.appearance.* = ...` writes — so the 15 + /// `store_state_mutation_in_views` SwiftLint rule stays clean and 16 + /// reducer tests can exercise every code path. 17 + struct RepositoryAppearancePickerView: View { 18 + @Bindable var store: StoreOf<RepositorySettingsFeature> 19 + 20 + @State private var isSymbolPickerPresented = false 21 + @State private var isImageImporterPresented = false 22 + @State private var isHoveringIconTile = false 23 + 24 + private let previewSize: CGFloat = 40 25 + /// Diameter of the colored swatch itself. 26 + private let swatchDotSize: CGFloat = 20 27 + /// Diameter of the "selected" ring drawn around the swatch. The 28 + /// (ring − dot) / 2 gap (3pt) lives between the dot's edge and the 29 + /// ring's inside, matching macOS-native color pickers. 30 + private let swatchRingSize: CGFloat = 26 31 + /// Outer slot every swatch occupies in the HStack so the row layout 32 + /// stays stable whether or not a swatch is selected (without a fixed 33 + /// slot the selection ring would expand the cell and shove its 34 + /// neighbors aside on hover/select). 35 + private let swatchSlotSize: CGFloat = 28 36 + private let swatchRingLineWidth: CGFloat = 1.5 37 + 38 + var body: some View { 39 + VStack(alignment: .leading, spacing: 12) { 40 + iconRow 41 + colorRow 42 + if let message = store.appearanceImportError { 43 + importErrorBanner(message: message) 44 + } 45 + } 46 + .sheet(isPresented: $isSymbolPickerPresented) { 47 + TabIconPickerView( 48 + initialIcon: currentSymbolName, 49 + defaultIcon: "folder.fill", 50 + title: "Repository Icon", 51 + subtitle: 52 + "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " 53 + + "with the repo color; bitmap formats keep their own colors.", 54 + presets: RepositoryIconPresets.presets, 55 + onApply: { applySymbolFromPicker($0) }, 56 + onCancel: { isSymbolPickerPresented = false } 57 + ) 58 + } 59 + // Accept any image UTType — PNG / JPEG / WebP / HEIC / TIFF / GIF 60 + // / etc. all flow through the same `NSImage(contentsOf:)` render 61 + // path. SVG is listed explicitly because it's a structured-text 62 + // format that doesn't always conform to `.image` in older 63 + // UTType conformance tables. Anything that fails to decode falls 64 + // back to the dashed placeholder at render time. 65 + .fileImporter( 66 + isPresented: $isImageImporterPresented, 67 + allowedContentTypes: [.image, .svg], 68 + allowsMultipleSelection: false 69 + ) { result in 70 + handleImageImportResult(result) 71 + } 72 + } 73 + 74 + // MARK: - Icon row 75 + 76 + @ViewBuilder 77 + private var iconRow: some View { 78 + HStack(alignment: .center, spacing: 12) { 79 + iconMenu 80 + VStack(alignment: .leading, spacing: 4) { 81 + Text("Icon") 82 + .font(.headline) 83 + Text(iconHelpText) 84 + .font(.caption) 85 + .foregroundStyle(.secondary) 86 + .fixedSize(horizontal: false, vertical: true) 87 + } 88 + Spacer(minLength: 0) 89 + } 90 + } 91 + 92 + /// Click target + menu trigger for the icon. The whole preview tile 93 + /// is the action surface — clicking opens a popover menu with the 94 + /// three options. Drops the trailing button cluster so narrow 95 + /// Settings windows don't truncate "Choose Symbol…" / "Clear Icon". 96 + /// Pattern matches macOS native flows (System Settings user picture, 97 + /// Finder "Get Info" icon). 98 + /// 99 + /// Implementation notes — `.buttonStyle(.plain)` (NOT 100 + /// `.menuStyle(.borderlessButton)`) is critical: the borderless 101 + /// menu style applies its own padding and a system tint that 102 + /// silently overrides the icon's `foregroundStyle`, so the user's 103 + /// chosen color stops appearing on the preview. Plain button style 104 + /// lets the label render exactly as authored. Hover detection, 105 + /// overlay border, pointer style, and tooltip all sit on the 106 + /// **outer** Menu — `.onHover` placed inside the Menu's label is 107 + /// swallowed by the menu's pointer interception. 108 + @ViewBuilder 109 + private var iconMenu: some View { 110 + Menu { 111 + Button("Choose Symbol…") { 112 + isSymbolPickerPresented = true 113 + } 114 + Button("Choose Image…") { 115 + isImageImporterPresented = true 116 + } 117 + if store.appearance.icon != nil { 118 + Divider() 119 + Button("Clear Icon", role: .destructive) { 120 + store.send(.setAppearanceIcon(nil)) 121 + } 122 + } 123 + } label: { 124 + iconPreviewTile 125 + } 126 + .buttonStyle(.plain) 127 + .menuIndicator(.hidden) 128 + .fixedSize() 129 + .overlay { 130 + RoundedRectangle(cornerRadius: 8, style: .continuous) 131 + .stroke( 132 + Color.accentColor.opacity(isHoveringIconTile ? 0.65 : 0), 133 + lineWidth: 1.5 134 + ) 135 + } 136 + .onHover { isHoveringIconTile = $0 } 137 + .pointerStyle(.link) 138 + .animation(.easeOut(duration: 0.12), value: isHoveringIconTile) 139 + .help("Click the icon preview to pick a symbol or import an image") 140 + } 141 + 142 + /// Visual label for the menu trigger. The frame is locked to 143 + /// `previewSize × previewSize` so the row layout (and the title / 144 + /// description text alignment to its right) doesn't shift when the 145 + /// inner content swaps between the user's icon and the 146 + /// no-icon placeholder. `.contentShape` confines the click hit-test 147 + /// to the rounded rectangle so clicks just outside the visible 148 + /// tile don't open the menu. 149 + /// 150 + /// When no icon is set, a dashed border replaces the questionmark's 151 + /// silence with a "drop zone"-style affordance — the same pattern 152 + /// macOS uses for empty avatar / drag-target slots — so users see 153 + /// the tile is interactive even before they hover. 154 + @ViewBuilder 155 + private var iconPreviewTile: some View { 156 + let frame = RoundedRectangle(cornerRadius: 8, style: .continuous) 157 + let fill = Color.secondary.opacity(0.12) 158 + let hasIcon = store.appearance.icon != nil 159 + Group { 160 + if let icon = store.appearance.icon { 161 + RepositoryIconImage( 162 + icon: icon, 163 + repositoryRootURL: store.rootURL, 164 + tintColor: tintColor, 165 + size: 22 166 + ) 167 + } else { 168 + Image(systemName: "questionmark") 169 + .font(.system(size: 16, weight: .semibold)) 170 + .foregroundStyle(.tertiary) 171 + .accessibilityHidden(true) 172 + } 173 + } 174 + .frame(width: previewSize, height: previewSize) 175 + .background(fill, in: frame) 176 + .overlay { 177 + if !hasIcon { 178 + frame.stroke( 179 + Color.secondary.opacity(0.55), 180 + style: StrokeStyle(lineWidth: 1, dash: [3, 3]) 181 + ) 182 + } 183 + } 184 + .contentShape(.rect(cornerRadius: 8, style: .continuous)) 185 + .accessibilityLabel("Icon picker") 186 + } 187 + 188 + private var iconHelpText: String { 189 + switch store.appearance.icon { 190 + case .userImage(let filename) where !filename.lowercased().hasSuffix(".svg"): 191 + return "Bitmap icons keep their original colors and ignore the repo color." 192 + case .userImage: 193 + return "User-provided SVGs are tinted with the repo color." 194 + case .sfSymbol: 195 + return "SF Symbols pick up the repo color when one is set." 196 + case .bundledAsset: 197 + return "Bundled icons keep their original artwork." 198 + case nil: 199 + return "No icon set. Click the icon preview to pick a symbol or import an image." 200 + } 201 + } 202 + 203 + private var tintColor: Color { 204 + store.appearance.color?.color ?? .accentColor 205 + } 206 + 207 + private var currentSymbolName: String? { 208 + if case .sfSymbol(let name) = store.appearance.icon { 209 + return name 210 + } 211 + return nil 212 + } 213 + 214 + // MARK: - Color row 215 + 216 + @ViewBuilder 217 + private var colorRow: some View { 218 + VStack(alignment: .leading, spacing: 8) { 219 + Text("Color") 220 + .font(.headline) 221 + Text( 222 + "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." 223 + ) 224 + .font(.caption) 225 + .foregroundStyle(.secondary) 226 + .fixedSize(horizontal: false, vertical: true) 227 + HStack(spacing: 8) { 228 + ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in 229 + colorSwatch(for: choice) 230 + } 231 + noColorSwatch 232 + Spacer(minLength: 0) 233 + } 234 + } 235 + } 236 + 237 + @ViewBuilder 238 + private func colorSwatch(for choice: RepositoryColorChoice) -> some View { 239 + let isSelected = store.appearance.color == choice 240 + Button { 241 + store.send(.setAppearanceColor(choice)) 242 + } label: { 243 + swatchSlot(isSelected: isSelected) { 244 + Circle() 245 + .fill(choice.color) 246 + .frame(width: swatchDotSize, height: swatchDotSize) 247 + } 248 + .help(choice.displayName) 249 + .accessibilityLabel(choice.displayName) 250 + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) 251 + } 252 + .buttonStyle(.plain) 253 + } 254 + 255 + @ViewBuilder 256 + private var noColorSwatch: some View { 257 + let isSelected = store.appearance.color == nil 258 + Button { 259 + store.send(.setAppearanceColor(nil)) 260 + } label: { 261 + swatchSlot(isSelected: isSelected) { 262 + Circle() 263 + .stroke( 264 + Color.secondary.opacity(0.5), 265 + style: StrokeStyle(lineWidth: 1, dash: [2, 2]) 266 + ) 267 + .frame(width: swatchDotSize, height: swatchDotSize) 268 + .overlay { 269 + Image(systemName: "slash.circle") 270 + .font(.system(size: 11)) 271 + .foregroundStyle(.secondary) 272 + .accessibilityHidden(true) 273 + } 274 + } 275 + .help("No color") 276 + .accessibilityLabel("No color") 277 + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) 278 + } 279 + .buttonStyle(.plain) 280 + } 281 + 282 + /// Centers the swatch content in a fixed-size slot and overlays a 283 + /// selection ring on top, sized larger than the swatch so a 3pt 284 + /// transparent gap separates the swatch's edge from the ring's 285 + /// inside — mirroring macOS-native color picker selection chrome. 286 + /// The slot bounds stay constant whether selected or not so swatch 287 + /// row layout doesn't reflow on selection change. 288 + @ViewBuilder 289 + private func swatchSlot<Content: View>( 290 + isSelected: Bool, @ViewBuilder content: () -> Content 291 + ) -> some View { 292 + ZStack { 293 + content() 294 + if isSelected { 295 + Circle() 296 + .stroke(Color.primary, lineWidth: swatchRingLineWidth) 297 + .frame(width: swatchRingSize, height: swatchRingSize) 298 + } 299 + } 300 + .frame(width: swatchSlotSize, height: swatchSlotSize) 301 + } 302 + 303 + // MARK: - Error banner 304 + 305 + @ViewBuilder 306 + private func importErrorBanner(message: String) -> some View { 307 + HStack(spacing: 6) { 308 + Image(systemName: "exclamationmark.triangle.fill") 309 + .foregroundStyle(.orange) 310 + .accessibilityHidden(true) 311 + Text(message) 312 + .font(.caption) 313 + .foregroundStyle(.primary) 314 + Spacer(minLength: 0) 315 + Button("Dismiss") { 316 + store.send(.dismissAppearanceImportError) 317 + } 318 + .buttonStyle(.plain) 319 + .font(.caption) 320 + .foregroundStyle(.secondary) 321 + } 322 + .padding(.vertical, 4) 323 + .padding(.horizontal, 8) 324 + .background( 325 + RoundedRectangle(cornerRadius: 6, style: .continuous) 326 + .fill(Color.orange.opacity(0.12)) 327 + ) 328 + } 329 + 330 + // MARK: - Actions 331 + 332 + private func applySymbolFromPicker(_ name: String?) { 333 + isSymbolPickerPresented = false 334 + if let name { 335 + store.send(.setAppearanceIcon(.sfSymbol(name))) 336 + } else { 337 + store.send(.setAppearanceIcon(nil)) 338 + } 339 + } 340 + 341 + private func handleImageImportResult(_ result: Result<[URL], Error>) { 342 + isImageImporterPresented = false 343 + switch result { 344 + case .success(let urls): 345 + guard let url = urls.first else { return } 346 + // `fileImporter` returns security-scoped URLs on macOS — we need 347 + // to start access before reading and stop it on the way out so 348 + // the import store can copy the bytes into the sandboxed app 349 + // support directory. 350 + let needsScope = url.startAccessingSecurityScopedResource() 351 + defer { 352 + if needsScope { url.stopAccessingSecurityScopedResource() } 353 + } 354 + store.send(.importUserImage(url)) 355 + case .failure(let error): 356 + store.send(.userImageImportFailed(error.localizedDescription)) 357 + } 358 + } 359 + }
+12
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 66 66 let exampleWorktreePath = store.exampleWorktreePath 67 67 68 68 Form { 69 + Section { 70 + RepositoryAppearancePickerView(store: store) 71 + } header: { 72 + VStack(alignment: .leading, spacing: 4) { 73 + Text("Appearance") 74 + Text( 75 + "Pick an icon and color to make this repository easy to spot in the sidebar, shelf, and canvas." 76 + ) 77 + .foregroundStyle(.secondary) 78 + } 79 + } 80 + 69 81 if store.showsWorktreeSettings { 70 82 Section { 71 83 if store.isBranchDataLoaded {
+120 -12
supacode/Features/Shelf/Views/ShelfSpineView.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 4 /// Vertical spine rendering for a single book on the Shelf. ··· 31 32 /// enum into this view. 32 33 let closeMenuTitle: String 33 34 let onCloseBook: (() -> Void)? 35 + /// "Repo Settings" — opens the per-repo Settings tab. Always 36 + /// available regardless of book kind since every book belongs to a 37 + /// repository. 38 + let onOpenRepositorySettings: () -> Void 34 39 35 40 @State private var isHovering = false 41 + @Shared(.repositoryAppearances) private var repositoryAppearances 36 42 37 43 var body: some View { 38 44 VStack(spacing: 0) { ··· 98 104 /// bumps its tint to 80% of the selected book's intensity — a clear 99 105 /// "this is interactable" affordance that sits just below the open 100 106 /// book and animates in/out smoothly. 107 + /// 108 + /// When the book's repository has a user-pinned color, that color 109 + /// replaces `Color.accentColor` as the proximity-tint base so the 110 + /// shelf reads as "books on shelves" instead of one continuous 111 + /// accent ribbon. The proximity ladder is unchanged — we only swap 112 + /// the hue. 101 113 private var spineBackgroundColor: Color { 102 114 guard distanceFromOpen != nil else { 103 115 return Color.primary.opacity(0.06) 104 116 } 105 117 let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier 106 - return Color.accentColor.opacity(0.20 * multiplier) 118 + return effectiveTintColor.opacity(0.20 * multiplier) 119 + } 120 + 121 + /// Repo's pinned color, or `.accentColor` when none — used as the 122 + /// proximity-tint base and as the icon tint in the header. 123 + private var effectiveTintColor: Color { 124 + appearance.color?.color ?? .accentColor 125 + } 126 + 127 + private var appearance: RepositoryAppearance { 128 + repositoryAppearances[book.repositoryID] ?? .empty 107 129 } 108 130 109 131 /// Active-tab highlight fades more gently than the spine background — ··· 122 144 123 145 @ViewBuilder 124 146 private var bookContextMenu: some View { 147 + Button { 148 + onOpenRepositorySettings() 149 + } label: { 150 + Text("Repo Settings") 151 + } 125 152 if let onCloseBook { 153 + Divider() 126 154 Button { 127 155 onCloseBook() 128 156 } label: { ··· 164 192 } 165 193 } 166 194 .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) 195 + .padding(.top, ShelfMetrics.sectionGap) 167 196 .padding(.bottom, ShelfMetrics.slotSpacing) 168 197 } 169 198 } ··· 173 202 Button(action: onOpenBook) { 174 203 ShelfSpineHeader( 175 204 book: book, 176 - hasAggregatedNotification: terminalState?.hasUnseenNotification == true 205 + hasAggregatedNotification: terminalState?.hasUnseenNotification == true, 206 + icon: appearance.icon, 207 + iconTint: effectiveTintColor, 208 + repositoryRootURL: URL(fileURLWithPath: book.repositoryID) 177 209 ) 178 210 .frame(maxWidth: .infinity) 179 211 .contentShape(.rect) ··· 217 249 hotkeyIndex: hotkeyIndex, 218 250 isActive: terminalState.tabManager.selectedTabId == tab.id, 219 251 hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id), 252 + activeHighlightTint: effectiveTintColor, 220 253 activeHighlightAlpha: activeTabHighlightAlpha, 221 254 onTap: { onSelectTab(tab.id) }, 222 255 onClose: { terminalState.closeTab(tab.id) } ··· 236 269 } 237 270 } 238 271 .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) 239 - .padding(.top, ShelfMetrics.slotSpacing) 272 + .padding(.top, ShelfMetrics.sectionGap) 240 273 } 241 274 242 275 } ··· 244 277 private struct ShelfSpineHeader: View { 245 278 let book: ShelfBook 246 279 let hasAggregatedNotification: Bool 280 + let icon: RepositoryIconSource? 281 + let iconTint: Color 282 + let repositoryRootURL: URL 283 + 284 + /// Reserved slot for the top decoration (icon and/or notification), 285 + /// sized at the maximum expected configuration (14pt icon plus a 286 + /// 6pt badge nudged 3pt outward at the top-trailing corner). 287 + /// Holding the slot at a constant size — whether or not an icon is 288 + /// set — keeps every spine's header at the same total height so the 289 + /// rotated titles align horizontally across the shelf row. When the 290 + /// repo has no icon AND no notification, the slot is just empty 291 + /// reserved space. 292 + private let slotSize: CGFloat = 18 293 + private let iconSize: CGFloat = 14 294 + private let badgeSize: CGFloat = 6 295 + private let badgeOffset: CGFloat = 3 247 296 248 297 var body: some View { 249 - VStack(spacing: 6) { 250 - Circle() 251 - .fill(.orange) 252 - .frame(width: ShelfMetrics.aggregatedDotSize, height: ShelfMetrics.aggregatedDotSize) 253 - .opacity(hasAggregatedNotification ? 1 : 0) 254 - .accessibilityLabel("Unread notifications") 255 - .accessibilityHidden(!hasAggregatedNotification) 256 - .padding(.top, 6) 298 + VStack(spacing: 8) { 299 + slot 257 300 rotatedTitle 258 301 } 302 + .padding(.top, 8) 303 + } 304 + 305 + /// Three rendering paths driven by the (icon, notification) matrix: 306 + /// - icon set: render the icon, hang the notification on it as a 307 + /// small badge in the top-trailing corner (macOS app-icon style). 308 + /// - no icon, has notification: fall back to the original 309 + /// standalone orange dot, centered in the slot. 310 + /// - no icon, no notification: slot stays empty but reserved. 311 + @ViewBuilder 312 + private var slot: some View { 313 + ZStack { 314 + Color.clear 315 + .frame(width: slotSize, height: slotSize) 316 + 317 + if let icon { 318 + RepositoryIconImage( 319 + icon: icon, 320 + repositoryRootURL: repositoryRootURL, 321 + tintColor: iconTint, 322 + size: iconSize 323 + ) 324 + .overlay(alignment: .topTrailing) { 325 + if hasAggregatedNotification { 326 + notificationBadge 327 + } 328 + } 329 + } else if hasAggregatedNotification { 330 + Circle() 331 + .fill(.orange) 332 + .frame( 333 + width: ShelfMetrics.aggregatedDotSize, 334 + height: ShelfMetrics.aggregatedDotSize 335 + ) 336 + } 337 + } 338 + .accessibilityElement() 339 + .accessibilityLabel(hasAggregatedNotification ? "Unread notifications" : "") 340 + .accessibilityHidden(!hasAggregatedNotification) 341 + } 342 + 343 + /// Notification dot rendered as a corner badge over the icon. The 344 + /// thin dark stroke keeps the orange visible on light spine 345 + /// backgrounds; without it the badge would disappear on 346 + /// orange-tinted repos. 347 + @ViewBuilder 348 + private var notificationBadge: some View { 349 + Circle() 350 + .fill(.orange) 351 + .frame(width: badgeSize, height: badgeSize) 352 + .overlay { 353 + Circle().stroke(Color.black.opacity(0.25), lineWidth: 0.5) 354 + } 355 + .offset(x: badgeOffset, y: -badgeOffset) 259 356 } 260 357 261 358 /// Composed title rendered vertically (top-to-bottom reading direction). ··· 294 391 let hotkeyIndex: Int? 295 392 let isActive: Bool 296 393 let hasUnseenNotification: Bool 394 + /// Hue used for the active-tab background fill — repo color when 395 + /// the owning book has one pinned, otherwise `Color.accentColor`. 396 + /// Threaded from the spine so the active-tab indicator stays in 397 + /// the same color family as the surrounding spine background 398 + /// instead of clashing with a contrasting accent. 399 + let activeHighlightTint: Color 297 400 /// Absolute alpha for the active-tab accent fill, supplied by the 298 401 /// enclosing spine so it can fade with proximity on its own curve 299 402 /// (which decays more gently than the spine background — selection ··· 377 480 .fill(Color.orange.opacity(0.3)) 378 481 } else if isActive { 379 482 RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) 380 - .fill(Color.accentColor.opacity(activeHighlightAlpha)) 483 + .fill(activeHighlightTint.opacity(activeHighlightAlpha)) 381 484 } else { 382 485 Color.clear 383 486 } ··· 418 521 static let slotCornerRadius: CGFloat = 5 419 522 static let slotSpacing: CGFloat = 3 420 523 static let slotHorizontalPadding: CGFloat = 3 524 + /// Vertical gap between major spine sections (header → tab list, 525 + /// tab list → bottom controls). Larger than `slotSpacing` so the 526 + /// rotated title doesn't crowd into the first tab and the last tab 527 + /// doesn't crowd into the divider above the `+` button. 528 + static let sectionGap: CGFloat = 10 421 529 static let aggregatedDotSize: CGFloat = 6 422 530 /// Max pre-rotation width (i.e. visual height after 90° rotation) of the 423 531 /// spine header title. Texts longer than this get middle-truncated.
+4 -1
supacode/Features/Shelf/Views/ShelfView.swift
··· 89 89 onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil, 90 90 onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil, 91 91 closeMenuTitle: closeMenuTitle(for: book), 92 - onCloseBook: { closeBook(book) } 92 + onCloseBook: { closeBook(book) }, 93 + onOpenRepositorySettings: { 94 + store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID))) 95 + } 93 96 ) 94 97 .matchedGeometryEffect(id: book.id, in: spineNamespace) 95 98 }
+12 -3
supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift
··· 4 4 struct TabIconPickerView: View { 5 5 let initialIcon: String? 6 6 let defaultIcon: String 7 + let title: String 8 + let subtitle: String 9 + let presets: [String] 7 10 let onApply: (String?) -> Void 8 11 let onCancel: () -> Void 9 12 ··· 13 16 init( 14 17 initialIcon: String?, 15 18 defaultIcon: String, 19 + title: String = "Tab Icon", 20 + subtitle: String = "Pick a preset or enter any SF Symbol name available in your system.", 21 + presets: [String] = TabIconPickerView.symbolPresets, 16 22 onApply: @escaping (String?) -> Void, 17 23 onCancel: @escaping () -> Void 18 24 ) { 19 25 self.initialIcon = initialIcon 20 26 self.defaultIcon = defaultIcon 27 + self.title = title 28 + self.subtitle = subtitle 29 + self.presets = presets 21 30 self.onApply = onApply 22 31 self.onCancel = onCancel 23 32 _symbolName = State(initialValue: initialIcon ?? "") ··· 26 35 var body: some View { 27 36 VStack(alignment: .leading, spacing: 16) { 28 37 VStack(alignment: .leading, spacing: 4) { 29 - Text("Tab Icon") 38 + Text(title) 30 39 .font(.headline) 31 - Text("Pick a preset or enter any SF Symbol name available in your system.") 40 + Text(subtitle) 32 41 .font(.caption) 33 42 .foregroundStyle(.secondary) 34 43 } ··· 56 65 columns: Array(repeating: GridItem(.fixed(32), spacing: 6), count: 8), 57 66 spacing: 6 58 67 ) { 59 - ForEach(Self.symbolPresets, id: \.self) { symbol in 68 + ForEach(presets, id: \.self) { symbol in 60 69 Button { 61 70 symbolName = symbol 62 71 symbolFieldFocused = true
+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,
+58
supacodeTests/AppFeatureSettingsSelectionTests.swift
··· 1 1 import ComposableArchitecture 2 + import Dependencies 3 + import DependenciesTestSupport 2 4 import Foundation 5 + import Sharing 3 6 import Testing 4 7 5 8 @testable import supacode ··· 26 29 $0.settings.selection = .repository(repository.id) 27 30 $0.settings.repositorySettings = RepositorySettingsFeature.State( 28 31 rootURL: repository.rootURL, 32 + repositoryID: repository.id, 29 33 repositoryKind: repository.kind, 30 34 settings: .default, 31 35 userSettings: .default ··· 70 74 $0.settings.selection = .repository(repository.id) 71 75 $0.settings.repositorySettings = RepositorySettingsFeature.State( 72 76 rootURL: repository.rootURL, 77 + repositoryID: repository.id, 73 78 repositoryKind: .plain, 74 79 settings: .default, 75 80 userSettings: .default 76 81 ) 82 + } 83 + } 84 + 85 + @Test(.dependencies) func selectingRepositorySeedsAppearanceSynchronously() async { 86 + // Regression: selecting a repo whose appearance is already in 87 + // @Shared used to construct a State with `.empty` appearance and 88 + // load asynchronously via .task. The async hop raced with the 89 + // user's first click, sometimes wiping previously-saved fields. 90 + // The State must now carry the appearance from frame zero. 91 + let storage = SettingsTestStorage() 92 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 93 + let savedAppearance = RepositoryAppearance( 94 + icon: .sfSymbol("hammer.fill"), color: .blue 95 + ) 96 + let repository = Repository( 97 + id: "appearance-repo", 98 + rootURL: URL(fileURLWithPath: "/tmp/appearance-repo"), 99 + name: "AppearanceRepo", 100 + worktrees: [] 101 + ) 102 + 103 + await withDependencies { 104 + $0.settingsFileStorage = storage.storage 105 + $0.repositoryAppearancesFileURL = appearancesURL 106 + } operation: { 107 + @Shared(.repositoryAppearances) var appearances 108 + $appearances.withLock { 109 + $0[repository.id] = savedAppearance 110 + } 111 + 112 + let store = TestStore( 113 + initialState: AppFeature.State( 114 + repositories: RepositoriesFeature.State(repositories: [repository]), 115 + settings: SettingsFeature.State() 116 + ) 117 + ) { 118 + AppFeature() 119 + } withDependencies: { 120 + $0.settingsFileStorage = storage.storage 121 + $0.repositoryAppearancesFileURL = appearancesURL 122 + } 123 + 124 + await store.send(.settings(.setSelection(.repository(repository.id)))) { 125 + $0.settings.selection = .repository(repository.id) 126 + $0.settings.repositorySettings = RepositorySettingsFeature.State( 127 + rootURL: repository.rootURL, 128 + repositoryID: repository.id, 129 + repositoryKind: repository.kind, 130 + settings: .default, 131 + userSettings: .default, 132 + appearance: savedAppearance 133 + ) 134 + } 77 135 } 78 136 } 79 137
+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 + }
+182
supacodeTests/RepositoryIconAssetStoreTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + /// RAII helper: creates a unique scratch directory under `$TMPDIR` and 7 + /// removes it on deinit so test runs don't accumulate orphan folders 8 + /// between invocations (we ship to `make test` repeatedly during dev). 9 + private final class ScratchDirectory { 10 + let url: URL 11 + 12 + init(prefix: String) { 13 + url = URL(fileURLWithPath: NSTemporaryDirectory()) 14 + .appending(path: "\(prefix)-\(UUID().uuidString)", directoryHint: .isDirectory) 15 + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) 16 + } 17 + 18 + deinit { 19 + try? FileManager.default.removeItem(at: url) 20 + } 21 + } 22 + 23 + @MainActor 24 + struct RepositoryIconAssetStoreTests { 25 + // MARK: - Helpers 26 + 27 + private func makeRepoRootScratch() -> ScratchDirectory { 28 + ScratchDirectory(prefix: "prowl-icon-store") 29 + } 30 + 31 + private func writeSourceFile( 32 + in scratch: ScratchDirectory, 33 + extension ext: String, 34 + contents: Data = Data([0xDE, 0xAD]) 35 + ) throws -> URL { 36 + let url = scratch.url.appending(path: "icon.\(ext)", directoryHint: .notDirectory) 37 + try contents.write(to: url) 38 + return url 39 + } 40 + 41 + // MARK: - importImage 42 + 43 + @Test func importImageCopiesFileWithUUIDName() throws { 44 + let store = RepositoryIconAssetStore.liveValue 45 + let repoRoot = makeRepoRootScratch() 46 + let source = ScratchDirectory(prefix: "prowl-icon-source") 47 + let sourceFile = try writeSourceFile( 48 + in: source, extension: "png", contents: Data([0x01, 0x02, 0x03]) 49 + ) 50 + 51 + let filename = try store.importImage(sourceFile, repoRoot.url) 52 + 53 + #expect(filename.hasSuffix(".png")) 54 + #expect(UUID(uuidString: String(filename.dropLast(4))) != nil) 55 + 56 + let resolved = SupacodePaths.repositoryIconFileURL( 57 + filename: filename, repositoryRootURL: repoRoot.url 58 + ) 59 + let copied = try Data(contentsOf: resolved) 60 + #expect(copied == Data([0x01, 0x02, 0x03])) 61 + } 62 + 63 + @Test func importImageNormalizesUppercaseExtension() throws { 64 + let store = RepositoryIconAssetStore.liveValue 65 + let repoRoot = makeRepoRootScratch() 66 + let source = ScratchDirectory(prefix: "prowl-icon-source") 67 + let sourceFile = try writeSourceFile(in: source, extension: "PNG") 68 + 69 + let filename = try store.importImage(sourceFile, repoRoot.url) 70 + #expect(filename.hasSuffix(".png")) 71 + } 72 + 73 + @Test func importImageAcceptsSVG() throws { 74 + let store = RepositoryIconAssetStore.liveValue 75 + let repoRoot = makeRepoRootScratch() 76 + let source = ScratchDirectory(prefix: "prowl-icon-source") 77 + let sourceFile = try writeSourceFile(in: source, extension: "svg") 78 + 79 + let filename = try store.importImage(sourceFile, repoRoot.url) 80 + #expect(filename.hasSuffix(".svg")) 81 + } 82 + 83 + @Test func importImageAcceptsArbitraryImageExtensions() throws { 84 + // The store no longer enforces a PNG/SVG whitelist — the file 85 + // picker filters down to image UTTypes already, and anything 86 + // that NSImage can't decode falls back to a placeholder at 87 + // render time. JPG / WebP / HEIC / GIF / TIFF / etc. all flow 88 + // through the same byte-copy path and round-trip through 89 + // `repositoryIconFileURL` like PNG does. 90 + let store = RepositoryIconAssetStore.liveValue 91 + let repoRoot = makeRepoRootScratch() 92 + 93 + for ext in ["jpg", "jpeg", "webp", "heic", "gif", "tiff", "bmp"] { 94 + let source = ScratchDirectory(prefix: "prowl-icon-source") 95 + let sourceFile = try writeSourceFile(in: source, extension: ext) 96 + let filename = try store.importImage(sourceFile, repoRoot.url) 97 + #expect(filename.hasSuffix(".\(ext)")) 98 + } 99 + } 100 + 101 + @Test func importImageHandlesFileWithNoExtension() throws { 102 + // Defensive: a dragged-in file without an extension shouldn't 103 + // crash the importer. The destination filename gets a generic 104 + // fallback so the round-trip still works. 105 + let store = RepositoryIconAssetStore.liveValue 106 + let repoRoot = makeRepoRootScratch() 107 + let source = ScratchDirectory(prefix: "prowl-icon-source") 108 + let sourceFile = source.url.appending(path: "icon", directoryHint: .notDirectory) 109 + try Data([0xDE, 0xAD]).write(to: sourceFile) 110 + 111 + let filename = try store.importImage(sourceFile, repoRoot.url) 112 + #expect(!filename.isEmpty) 113 + } 114 + 115 + @Test func importImageCreatesIconsDirectoryWhenMissing() throws { 116 + let store = RepositoryIconAssetStore.liveValue 117 + let repoRoot = makeRepoRootScratch() 118 + // Don't pre-create the icons directory — importImage should make 119 + // it itself, otherwise first-time imports would fail. 120 + let source = ScratchDirectory(prefix: "prowl-icon-source") 121 + let sourceFile = try writeSourceFile(in: source, extension: "png") 122 + 123 + _ = try store.importImage(sourceFile, repoRoot.url) 124 + 125 + let iconsDir = SupacodePaths.repositoryIconsDirectory(for: repoRoot.url) 126 + var isDirectory: ObjCBool = false 127 + let exists = FileManager.default.fileExists( 128 + atPath: iconsDir.path(percentEncoded: false), isDirectory: &isDirectory 129 + ) 130 + #expect(exists) 131 + #expect(isDirectory.boolValue) 132 + } 133 + 134 + @Test func importImageGeneratesUniqueFilenamePerCall() throws { 135 + let store = RepositoryIconAssetStore.liveValue 136 + let repoRoot = makeRepoRootScratch() 137 + let source = ScratchDirectory(prefix: "prowl-icon-source") 138 + let sourceFile = try writeSourceFile(in: source, extension: "png") 139 + 140 + let first = try store.importImage(sourceFile, repoRoot.url) 141 + let second = try store.importImage(sourceFile, repoRoot.url) 142 + #expect(first != second) 143 + } 144 + 145 + // MARK: - exists 146 + 147 + @Test func existsReportsFalseWhenMissing() { 148 + let store = RepositoryIconAssetStore.liveValue 149 + let repoRoot = makeRepoRootScratch() 150 + #expect(!store.exists("nonexistent.png", repoRoot.url)) 151 + } 152 + 153 + @Test func existsReportsTrueAfterImport() throws { 154 + let store = RepositoryIconAssetStore.liveValue 155 + let repoRoot = makeRepoRootScratch() 156 + let source = ScratchDirectory(prefix: "prowl-icon-source") 157 + let sourceFile = try writeSourceFile(in: source, extension: "png") 158 + let filename = try store.importImage(sourceFile, repoRoot.url) 159 + #expect(store.exists(filename, repoRoot.url)) 160 + } 161 + 162 + // MARK: - remove 163 + 164 + @Test func removeDeletesImportedFile() throws { 165 + let store = RepositoryIconAssetStore.liveValue 166 + let repoRoot = makeRepoRootScratch() 167 + let source = ScratchDirectory(prefix: "prowl-icon-source") 168 + let sourceFile = try writeSourceFile(in: source, extension: "png") 169 + let filename = try store.importImage(sourceFile, repoRoot.url) 170 + 171 + try store.remove(filename, repoRoot.url) 172 + #expect(!store.exists(filename, repoRoot.url)) 173 + } 174 + 175 + @Test func removeIsIdempotent() throws { 176 + // Reset / replace flows can call remove repeatedly; missing files 177 + // shouldn't throw or the reducer would have to track existence. 178 + let store = RepositoryIconAssetStore.liveValue 179 + let repoRoot = makeRepoRootScratch() 180 + try store.remove("never-existed.png", repoRoot.url) 181 + } 182 + }
+37
supacodeTests/RepositoryIconPresetsTests.swift
··· 1 + import AppKit 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct RepositoryIconPresetsTests { 7 + @Test func presetsCountIsForty() { 8 + // Picker grid is 8 columns wide and we want full rows. Pinning 9 + // the count here catches accidental drops or duplicates during 10 + // refactors. 11 + #expect(RepositoryIconPresets.presets.count == 40) 12 + } 13 + 14 + @Test func presetsAreUnique() { 15 + let unique = Set(RepositoryIconPresets.presets) 16 + #expect(unique.count == RepositoryIconPresets.presets.count) 17 + } 18 + 19 + @Test func presetsHaveNoEmptyEntries() { 20 + for symbol in RepositoryIconPresets.presets { 21 + #expect(!symbol.isEmpty) 22 + } 23 + } 24 + 25 + @Test func everyPresetResolvesToARealSFSymbolOnThisOS() { 26 + // Catches a regression where a preset is added with a name like 27 + // `rocket.fill` that *looks* plausible but doesn't actually exist 28 + // (only `rocket` does, no `.fill` variant). A missing symbol 29 + // would render as a blank tile in the picker grid — invisible 30 + // bug. Run on the test machine's macOS, which is at least the 31 + // project's minimum (macOS 26+ per CLAUDE.md). 32 + let missing = 33 + RepositoryIconPresets.presets 34 + .filter { NSImage(systemSymbolName: $0, accessibilityDescription: nil) == nil } 35 + #expect(missing.isEmpty, "Unrecognized SF Symbols in presets: \(missing)") 36 + } 37 + }
+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 + }
+327
supacodeTests/RepositorySettingsAppearanceTests.swift
··· 1 + import ComposableArchitecture 2 + import Dependencies 3 + import DependenciesTestSupport 4 + import Foundation 5 + import Sharing 6 + import Testing 7 + 8 + @testable import supacode 9 + 10 + @MainActor 11 + struct RepositorySettingsAppearanceTests { 12 + // MARK: - Helpers 13 + 14 + private func makeStore( 15 + repositoryID: Repository.ID = "repo-1", 16 + initialAppearance: RepositoryAppearance = .empty, 17 + iconAssetStore: RepositoryIconAssetStore = .testNoOp, 18 + appearancesURL: URL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json"), 19 + settingsStorage: SettingsTestStorage = SettingsTestStorage() 20 + ) -> TestStore<RepositorySettingsFeature.State, RepositorySettingsFeature.Action> { 21 + TestStore( 22 + initialState: RepositorySettingsFeature.State( 23 + rootURL: URL(fileURLWithPath: "/tmp/\(repositoryID)"), 24 + repositoryID: repositoryID, 25 + repositoryKind: .plain, 26 + settings: .default, 27 + userSettings: .default, 28 + appearance: initialAppearance 29 + ) 30 + ) { 31 + RepositorySettingsFeature() 32 + } withDependencies: { 33 + $0.settingsFileStorage = settingsStorage.storage 34 + $0.repositoryAppearancesFileURL = appearancesURL 35 + $0.repositoryIconAssetStore = iconAssetStore 36 + } 37 + } 38 + 39 + // MARK: - Color 40 + 41 + @Test func setColorMutatesStateAndPersists() async throws { 42 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 43 + let settingsStorage = SettingsTestStorage() 44 + let store = makeStore(appearancesURL: appearancesURL, settingsStorage: settingsStorage) 45 + 46 + await store.send(.setAppearanceColor(.blue)) { 47 + $0.appearance.color = .blue 48 + } 49 + await store.finish() 50 + 51 + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) 52 + #expect(persisted["repo-1"]?.color == .blue) 53 + } 54 + 55 + @Test func setColorTwiceWithSameValueIsNoOp() async throws { 56 + let store = makeStore(initialAppearance: RepositoryAppearance(icon: nil, color: .red)) 57 + await store.send(.setAppearanceColor(.red)) 58 + await store.finish() 59 + } 60 + 61 + @Test func clearColorDropsEntryWhenIconAlsoNil() async throws { 62 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 63 + let settingsStorage = SettingsTestStorage() 64 + let store = makeStore( 65 + initialAppearance: RepositoryAppearance(icon: nil, color: .green), 66 + appearancesURL: appearancesURL, 67 + settingsStorage: settingsStorage 68 + ) 69 + 70 + await store.send(.setAppearanceColor(nil)) { 71 + $0.appearance.color = nil 72 + } 73 + await store.finish() 74 + 75 + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) 76 + #expect(persisted["repo-1"] == nil) 77 + } 78 + 79 + // MARK: - Icon 80 + 81 + @Test func setIconMutatesStateAndPersists() async throws { 82 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 83 + let settingsStorage = SettingsTestStorage() 84 + let store = makeStore(appearancesURL: appearancesURL, settingsStorage: settingsStorage) 85 + 86 + await store.send(.setAppearanceIcon(.sfSymbol("folder.fill"))) { 87 + $0.appearance.icon = .sfSymbol("folder.fill") 88 + } 89 + await store.finish() 90 + 91 + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) 92 + #expect(persisted["repo-1"]?.icon == .sfSymbol("folder.fill")) 93 + } 94 + 95 + @Test func clearingUserImageIconRemovesFileFromDisk() async throws { 96 + let removed = LockIsolated<[(String, URL)]>([]) 97 + let store = makeStore( 98 + initialAppearance: RepositoryAppearance( 99 + icon: .userImage(filename: "old.png"), color: .blue 100 + ), 101 + iconAssetStore: .testRecording(removed: removed) 102 + ) 103 + 104 + await store.send(.setAppearanceIcon(nil)) { 105 + $0.appearance.icon = nil 106 + } 107 + await store.finish() 108 + 109 + #expect(removed.value.count == 1) 110 + #expect(removed.value.first?.0 == "old.png") 111 + } 112 + 113 + @Test func replacingUserImageRemovesPreviousFile() async throws { 114 + let removed = LockIsolated<[(String, URL)]>([]) 115 + let store = makeStore( 116 + initialAppearance: RepositoryAppearance( 117 + icon: .userImage(filename: "old.png"), color: nil 118 + ), 119 + iconAssetStore: .testRecording(removed: removed) 120 + ) 121 + 122 + await store.send(.setAppearanceIcon(.sfSymbol("folder"))) { 123 + $0.appearance.icon = .sfSymbol("folder") 124 + } 125 + await store.finish() 126 + 127 + #expect(removed.value.count == 1) 128 + #expect(removed.value.first?.0 == "old.png") 129 + } 130 + 131 + @Test func switchingFromSymbolToUserImageDoesNotTriggerRemoval() async throws { 132 + let removed = LockIsolated<[(String, URL)]>([]) 133 + let store = makeStore( 134 + initialAppearance: RepositoryAppearance(icon: .sfSymbol("folder"), color: nil), 135 + iconAssetStore: .testRecording(removed: removed) 136 + ) 137 + 138 + await store.send(.setAppearanceIcon(.userImage(filename: "new.png"))) { 139 + $0.appearance.icon = .userImage(filename: "new.png") 140 + } 141 + await store.finish() 142 + 143 + #expect(removed.value.isEmpty) 144 + } 145 + 146 + @Test func sameUserImageReassignmentDoesNotTriggerRemoval() async throws { 147 + // Re-applying the same icon (e.g. an idempotent reducer flow) must 148 + // not delete the asset out from under the new state. 149 + let removed = LockIsolated<[(String, URL)]>([]) 150 + let initial = RepositoryAppearance( 151 + icon: .userImage(filename: "stable.svg"), color: nil 152 + ) 153 + let store = makeStore(initialAppearance: initial, iconAssetStore: .testRecording(removed: removed)) 154 + 155 + await store.send(.setAppearanceIcon(.userImage(filename: "stable.svg"))) 156 + await store.finish() 157 + 158 + #expect(removed.value.isEmpty) 159 + } 160 + 161 + // MARK: - Import 162 + 163 + @Test func importUserImageDispatchesImportedAction() async throws { 164 + let store = makeStore( 165 + iconAssetStore: RepositoryIconAssetStore( 166 + importImage: { _, _ in "abc.svg" }, 167 + remove: { _, _ in }, 168 + exists: { _, _ in true } 169 + ) 170 + ) 171 + 172 + await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.svg"))) 173 + await store.receive(\.userImageImported) { _ in 174 + // payload check below 175 + } 176 + await store.receive(\.setAppearanceIcon) { 177 + $0.appearance.icon = .userImage(filename: "abc.svg") 178 + } 179 + await store.finish() 180 + } 181 + 182 + @Test func importGenericErrorSurfacesLocalizedDescription() async throws { 183 + struct Boom: LocalizedError { var errorDescription: String? { "boom" } } 184 + let store = makeStore( 185 + iconAssetStore: RepositoryIconAssetStore( 186 + importImage: { _, _ in throw Boom() }, 187 + remove: { _, _ in }, 188 + exists: { _, _ in false } 189 + ) 190 + ) 191 + 192 + await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.svg"))) 193 + await store.receive(\.userImageImportFailed) { 194 + $0.appearanceImportError = "boom" 195 + } 196 + await store.finish() 197 + } 198 + 199 + @Test func dismissImportErrorClearsState() async throws { 200 + let store = makeStore() 201 + // Seed the error directly through the reducer surface. 202 + await store.send(.userImageImportFailed("boom")) { 203 + $0.appearanceImportError = "boom" 204 + } 205 + await store.send(.dismissAppearanceImportError) { 206 + $0.appearanceImportError = nil 207 + } 208 + } 209 + 210 + // MARK: - Reset 211 + 212 + @Test func resetAppearanceClearsBothAndRemovesUserImage() async throws { 213 + let removed = LockIsolated<[(String, URL)]>([]) 214 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 215 + let settingsStorage = SettingsTestStorage() 216 + let store = makeStore( 217 + initialAppearance: RepositoryAppearance( 218 + icon: .userImage(filename: "abc.png"), color: .blue 219 + ), 220 + iconAssetStore: .testRecording(removed: removed), 221 + appearancesURL: appearancesURL, 222 + settingsStorage: settingsStorage 223 + ) 224 + 225 + await store.send(.resetAppearance) { 226 + $0.appearance = .empty 227 + } 228 + await store.finish() 229 + 230 + #expect(removed.value.first?.0 == "abc.png") 231 + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) 232 + #expect(persisted["repo-1"] == nil) 233 + } 234 + 235 + @Test func resetAppearanceWhenAlreadyEmptyIsNoOp() async throws { 236 + let removed = LockIsolated<[(String, URL)]>([]) 237 + let store = makeStore(iconAssetStore: .testRecording(removed: removed)) 238 + await store.send(.resetAppearance) 239 + await store.finish() 240 + #expect(removed.value.isEmpty) 241 + } 242 + 243 + // MARK: - appearanceLoaded 244 + 245 + @Test func appearanceLoadedReplacesState() async throws { 246 + let store = makeStore() 247 + let loaded = RepositoryAppearance(icon: .sfSymbol("hammer"), color: .purple) 248 + await store.send(.appearanceLoaded(loaded)) { 249 + $0.appearance = loaded 250 + } 251 + } 252 + 253 + // MARK: - Regression 254 + 255 + @Test func pickingColorKeepsExistingIcon() async throws { 256 + // Regression: appearance used to be loaded via `.task` async, which 257 + // raced with the first click after reopening Settings — picking a 258 + // color before `.task` finished would write `{icon: nil, color: x}` 259 + // and wipe the previously-saved icon. The fix seeds appearance 260 + // synchronously when the State is built, so the user's first click 261 + // sees the right baseline. This test pins the new behavior: with 262 + // an icon pre-set in initial state, setting a color must preserve 263 + // the icon. 264 + let store = makeStore( 265 + initialAppearance: RepositoryAppearance(icon: .sfSymbol("hammer.fill"), color: nil) 266 + ) 267 + 268 + await store.send(.setAppearanceColor(.blue)) { 269 + $0.appearance.color = .blue 270 + } 271 + 272 + #expect(store.state.appearance.icon == .sfSymbol("hammer.fill")) 273 + #expect(store.state.appearance.color == .blue) 274 + } 275 + 276 + @Test func pickingIconKeepsExistingColor() async throws { 277 + // Mirror of the above for the symmetric case. 278 + let store = makeStore( 279 + initialAppearance: RepositoryAppearance(icon: nil, color: .red) 280 + ) 281 + 282 + await store.send(.setAppearanceIcon(.sfSymbol("folder.fill"))) { 283 + $0.appearance.icon = .sfSymbol("folder.fill") 284 + } 285 + 286 + #expect(store.state.appearance.color == .red) 287 + #expect(store.state.appearance.icon == .sfSymbol("folder.fill")) 288 + } 289 + 290 + // MARK: - Persistence helpers 291 + 292 + private func readAppearances( 293 + at url: URL, storage: SettingsTestStorage 294 + ) -> [Repository.ID: RepositoryAppearance] { 295 + guard let data = try? storage.storage.load(url) else { return [:] } 296 + return (try? JSONDecoder().decode([Repository.ID: RepositoryAppearance].self, from: data)) 297 + ?? [:] 298 + } 299 + } 300 + 301 + // MARK: - Test fixtures 302 + 303 + extension RepositoryIconAssetStore { 304 + /// Silent test fixture: import always returns an empty filename and 305 + /// remove/exists are no-ops. Use when the test doesn't care about 306 + /// either side's effects. 307 + fileprivate static let testNoOp = RepositoryIconAssetStore( 308 + importImage: { _, _ in "" }, 309 + remove: { _, _ in }, 310 + exists: { _, _ in false } 311 + ) 312 + 313 + /// Test fixture that records every `remove` call so a test can 314 + /// assert on cleanup behavior without poking at the real 315 + /// filesystem. 316 + fileprivate static func testRecording(removed: LockIsolated<[(String, URL)]>) 317 + -> RepositoryIconAssetStore 318 + { 319 + RepositoryIconAssetStore( 320 + importImage: { _, _ in "" }, 321 + remove: { filename, root in 322 + removed.withValue { $0.append((filename, root)) } 323 + }, 324 + exists: { _, _ in false } 325 + ) 326 + } 327 + }