···11+import Dependencies
22+import Foundation
33+44+/// File-system gateway for user-imported repository icon images. Wraps
55+/// the actual disk operations behind closures so both the live build
66+/// (real `FileManager`) and tests (in-memory) can drive the same code
77+/// paths without forking implementations.
88+///
99+/// All filenames returned by the store are bare names (e.g.
1010+/// `"3F2D…ABC.svg"`) — never absolute paths — so the persisted
1111+/// `RepositoryAppearance.icon` stays portable: moving a repository
1212+/// directory takes its icons with it without rewriting JSON.
1313+nonisolated struct RepositoryIconAssetStore: Sendable {
1414+ /// Imports a user-picked image into the per-repo icons directory and
1515+ /// returns the bare filename to persist. The implementation chooses
1616+ /// the filename (UUID + extension), creating intermediate directories
1717+ /// as needed.
1818+ var importImage:
1919+ @Sendable (
2020+ _ sourceURL: URL,
2121+ _ repositoryRootURL: URL
2222+ ) throws -> String
2323+2424+ /// Removes a previously-imported image. No-op when the file is
2525+ /// already gone (idempotent so reset/replace can call without
2626+ /// guarding against stale state).
2727+ var remove:
2828+ @Sendable (
2929+ _ filename: String,
3030+ _ repositoryRootURL: URL
3131+ ) throws -> Void
3232+3333+ /// Returns whether a previously-stored filename still resolves to an
3434+ /// existing file. Renderers use this to decide whether to fall back.
3535+ var exists:
3636+ @Sendable (
3737+ _ filename: String,
3838+ _ repositoryRootURL: URL
3939+ ) -> Bool
4040+}
4141+4242+nonisolated extension RepositoryIconAssetStore {
4343+ static var liveValue: RepositoryIconAssetStore {
4444+ RepositoryIconAssetStore(
4545+ importImage: { sourceURL, rootURL in
4646+ // No extension whitelist — the file picker filters down to
4747+ // image UTTypes already, and anything that NSImage can't
4848+ // render later falls back to the dashed-questionmark
4949+ // placeholder in `RepositoryIconImage`. The `.svg` suffix
5050+ // remains the lone meaningful signal because it gates the
5151+ // template-tinting branch downstream; everything else is
5252+ // treated as an opaque bitmap.
5353+ let normalizedExt =
5454+ sourceURL.pathExtension.lowercased().isEmpty
5555+ ? "img"
5656+ : sourceURL.pathExtension.lowercased()
5757+ let directory = SupacodePaths.repositoryIconsDirectory(for: rootURL)
5858+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
5959+ let filename = "\(UUID().uuidString.lowercased()).\(normalizedExt)"
6060+ let destination = directory.appending(path: filename, directoryHint: .notDirectory)
6161+ let data = try Data(contentsOf: sourceURL)
6262+ try data.write(to: destination, options: [.atomic])
6363+ return filename
6464+ },
6565+ remove: { filename, rootURL in
6666+ let url = SupacodePaths.repositoryIconFileURL(
6767+ filename: filename, repositoryRootURL: rootURL
6868+ )
6969+ if FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) {
7070+ try FileManager.default.removeItem(at: url)
7171+ }
7272+ },
7373+ exists: { filename, rootURL in
7474+ let url = SupacodePaths.repositoryIconFileURL(
7575+ filename: filename, repositoryRootURL: rootURL
7676+ )
7777+ return FileManager.default.fileExists(atPath: url.path(percentEncoded: false))
7878+ }
7979+ )
8080+ }
8181+}
8282+8383+nonisolated enum RepositoryIconAssetStoreKey: DependencyKey {
8484+ static var liveValue: RepositoryIconAssetStore { .liveValue }
8585+ static var previewValue: RepositoryIconAssetStore { .liveValue }
8686+ static var testValue: RepositoryIconAssetStore { .liveValue }
8787+}
8888+8989+extension DependencyValues {
9090+ nonisolated var repositoryIconAssetStore: RepositoryIconAssetStore {
9191+ get { self[RepositoryIconAssetStoreKey.self] }
9292+ set { self[RepositoryIconAssetStoreKey.self] = newValue }
9393+ }
9494+}
+27
supacode/Domain/RepositoryAppearance.swift
···11+import Foundation
22+33+/// User-pinned visual identity for a single repository: an optional
44+/// icon source and an optional color choice, both freely combinable.
55+/// Both fields are independently optional so a user can color-tag a
66+/// repo without picking an icon (and vice versa).
77+///
88+/// Persisted as part of a global `[Repository.ID: RepositoryAppearance]`
99+/// dictionary — not nested in `Repository` or `RepositorySettings` —
1010+/// because the sidebar / shelf / canvas all need O(1) cross-repo
1111+/// lookups during render and a single `@Shared` dict is the lightest
1212+/// way to give every renderer the same view.
1313+nonisolated struct RepositoryAppearance: Codable, Equatable, Hashable, Sendable {
1414+ var icon: RepositoryIconSource?
1515+ var color: RepositoryColorChoice?
1616+1717+ static let empty = RepositoryAppearance(icon: nil, color: nil)
1818+1919+ init(icon: RepositoryIconSource? = nil, color: RepositoryColorChoice? = nil) {
2020+ self.icon = icon
2121+ self.color = color
2222+ }
2323+2424+ var isEmpty: Bool {
2525+ icon == nil && color == nil
2626+ }
2727+}
+58
supacode/Domain/RepositoryColorChoice.swift
···11+import SwiftUI
22+33+/// One of a fixed palette of system-provided colors a user can pin to a
44+/// repository to make it identifiable in the sidebar, shelf spine, and
55+/// canvas card title bar. The palette is intentionally closed (10 colors)
66+/// to align with macOS Finder's tag colors and to keep `repoColor` a
77+/// purely semantic system color — never a custom hex — per the project's
88+/// "system provided only" rule.
99+///
1010+/// Persistence: encoded as the raw `String` (case name). New cases are
1111+/// safe to append; cases must never be renamed once shipped because user
1212+/// JSON references them by name.
1313+nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, Hashable {
1414+ case red
1515+ case orange
1616+ case yellow
1717+ case green
1818+ case mint
1919+ case cyan
2020+ case blue
2121+ case purple
2222+ case pink
2323+ case gray
2424+2525+ /// User-facing label for the color picker.
2626+ var displayName: String {
2727+ switch self {
2828+ case .red: "Red"
2929+ case .orange: "Orange"
3030+ case .yellow: "Yellow"
3131+ case .green: "Green"
3232+ case .mint: "Mint"
3333+ case .cyan: "Cyan"
3434+ case .blue: "Blue"
3535+ case .purple: "Purple"
3636+ case .pink: "Pink"
3737+ case .gray: "Gray"
3838+ }
3939+ }
4040+4141+ /// Resolved SwiftUI color. Only the bare named system colors are used
4242+ /// — never custom RGB — so the palette adapts to light/dark mode and
4343+ /// any future system tweaks.
4444+ var color: Color {
4545+ switch self {
4646+ case .red: .red
4747+ case .orange: .orange
4848+ case .yellow: .yellow
4949+ case .green: .green
5050+ case .mint: .mint
5151+ case .cyan: .cyan
5252+ case .blue: .blue
5353+ case .purple: .purple
5454+ case .pink: .pink
5555+ case .gray: .gray
5656+ }
5757+ }
5858+}
+71
supacode/Domain/RepositoryIconPresets.swift
···11+import Foundation
22+33+/// Repository-flavored SF Symbol picker presets. These are surfaced by
44+/// `RepositoryAppearancePickerView` (which reuses `TabIconPickerView`
55+/// with this list), distinct from the terminal-themed list the tab
66+/// picker ships. The split exists because the same picker is used in
77+/// two contexts that reach for different vocabulary: a tab picker
88+/// favors `play.fill` / `terminal` / `ladybug.fill`, a repo picker
99+/// favors `folder.fill` / `book.fill` / `hammer.fill`.
1010+///
1111+/// Order is loosely thematic so scanning the grid surfaces intent:
1212+/// folders → boxes / data → docs / books → tools / dev → network →
1313+/// art → nature / vibes.
1414+///
1515+/// All entries are restricted to SF Symbols 1–2 (macOS 11–12) baseline
1616+/// so they're guaranteed-available on the project's macOS 26 minimum.
1717+/// `.fill` variants are only listed when the symbol actually has one
1818+/// (e.g. `rocket.fill` was tried but doesn't exist — only `rocket`
1919+/// does, which renders as a question-mark placeholder if mistakenly
2020+/// suffixed).
2121+nonisolated enum RepositoryIconPresets {
2222+ static let presets: [String] = [
2323+ // Folders / containers (8)
2424+ "folder.fill",
2525+ "folder",
2626+ "folder.badge.plus",
2727+ "tray.fill",
2828+ "tray.full.fill",
2929+ "shippingbox.fill",
3030+ "archivebox.fill",
3131+ "externaldrive.fill",
3232+ // Docs / books (6)
3333+ "doc.fill",
3434+ "doc.text.fill",
3535+ "doc.richtext.fill",
3636+ "book.fill",
3737+ "books.vertical.fill",
3838+ "bookmark.fill",
3939+ // Tags / markers (3)
4040+ "tag.fill",
4141+ "flag.fill",
4242+ "paperplane.fill",
4343+ // Tools / dev (8)
4444+ "hammer.fill",
4545+ "wrench.fill",
4646+ "wrench.and.screwdriver.fill",
4747+ "screwdriver.fill",
4848+ "gearshape.fill",
4949+ "gear",
5050+ "cpu",
5151+ "ladybug.fill",
5252+ // Network / web (4)
5353+ "globe",
5454+ "network",
5555+ "server.rack",
5656+ "cloud.fill",
5757+ // Art (2)
5858+ "paintpalette.fill",
5959+ "paintbrush.fill",
6060+ // Nature / symbols (9)
6161+ "star.fill",
6262+ "heart.fill",
6363+ "leaf.fill",
6464+ "bolt.fill",
6565+ "sparkles",
6666+ "flame.fill",
6767+ "sun.max.fill",
6868+ "moon.fill",
6969+ "envelope.fill",
7070+ ]
7171+}
+81
supacode/Domain/RepositoryIconSource.swift
···11+import Foundation
22+33+/// Where a repository's icon comes from. Storage is a single string so
44+/// the on-disk JSON stays compact and migration-friendly; the marker
55+/// convention mirrors `TabIconSource` / `ResolvedTabIcon` so a future
66+/// reader looking at one knows the other.
77+///
88+/// - `sfSymbol`: a system SF Symbol name; tintable.
99+/// - `bundledAsset`: a name from the app's asset catalog (reserved for
1010+/// future branded presets; not user-importable).
1111+/// - `userImage`: a file the user dropped in via the picker, stored at
1212+/// `~/.prowl/repo/<name>/icons/<filename>`. Filename includes its
1313+/// extension so `isTintable` can distinguish PNG (no tint) from SVG.
1414+nonisolated enum RepositoryIconSource: Equatable, Hashable, Sendable {
1515+ case sfSymbol(String)
1616+ case bundledAsset(String)
1717+ case userImage(filename: String)
1818+1919+ static let assetMarker = "@asset:"
2020+ static let userImageMarker = "@file:"
2121+2222+ /// Round-tripped form for JSON storage. Bare strings stay SF Symbols
2323+ /// for forward-compat with anything else that learns the convention.
2424+ var storageString: String {
2525+ switch self {
2626+ case .sfSymbol(let name):
2727+ name
2828+ case .bundledAsset(let name):
2929+ Self.assetMarker + name
3030+ case .userImage(let filename):
3131+ Self.userImageMarker + filename
3232+ }
3333+ }
3434+3535+ /// Inverse of `storageString`. Returns `nil` for empty input so
3636+ /// callers can treat "no icon" and "blank string" identically.
3737+ static func parse(_ raw: String) -> RepositoryIconSource? {
3838+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
3939+ guard !trimmed.isEmpty else { return nil }
4040+ if trimmed.hasPrefix(userImageMarker) {
4141+ return .userImage(filename: String(trimmed.dropFirst(userImageMarker.count)))
4242+ }
4343+ if trimmed.hasPrefix(assetMarker) {
4444+ return .bundledAsset(String(trimmed.dropFirst(assetMarker.count)))
4545+ }
4646+ return .sfSymbol(trimmed)
4747+ }
4848+4949+ /// PNG keeps its own colors; SF Symbols and SVGs are tintable. Bundled
5050+ /// assets default to non-tintable so future additions don't repaint
5151+ /// branded artwork unintentionally — flip per-asset if/when needed.
5252+ var isTintable: Bool {
5353+ switch self {
5454+ case .sfSymbol:
5555+ true
5656+ case .bundledAsset:
5757+ false
5858+ case .userImage(let filename):
5959+ filename.lowercased().hasSuffix(".svg")
6060+ }
6161+ }
6262+}
6363+6464+extension RepositoryIconSource: Codable {
6565+ init(from decoder: Decoder) throws {
6666+ let container = try decoder.singleValueContainer()
6767+ let raw = try container.decode(String.self)
6868+ guard let parsed = Self.parse(raw) else {
6969+ throw DecodingError.dataCorruptedError(
7070+ in: container,
7171+ debugDescription: "Empty repository icon storage string"
7272+ )
7373+ }
7474+ self = parsed
7575+ }
7676+7777+ func encode(to encoder: Encoder) throws {
7878+ var container = encoder.singleValueContainer()
7979+ try container.encode(storageString)
8080+ }
8181+}
···44struct CanvasCardView: View {
55 let repositoryName: String
66 let worktreeName: String
77+ /// User-pinned icon for this card's repository, drawn before the
88+ /// repo name in the title bar. `nil` keeps the historical text-only
99+ /// title bar.
1010+ var repositoryIcon: RepositoryIconSource?
1111+ /// User-pinned color for this card's repository. When set, it tints
1212+ /// both the icon (if tintable) and the title-bar background as an
1313+ /// always-on identity strip.
1414+ var repositoryColor: Color?
1515+ /// Repo root URL, needed by `RepositoryIconImage` to resolve user
1616+ /// PNG/SVG filenames against the per-repo icons directory.
1717+ var repositoryRootURL: URL?
718 let tree: SplitTree<GhosttySurfaceView>
819 let isFocused: Bool
920 let isSelected: Bool
···109120110121 private var titleBar: some View {
111122 HStack(spacing: 6) {
123123+ if let repositoryIcon, let repositoryRootURL {
124124+ RepositoryIconImage(
125125+ icon: repositoryIcon,
126126+ repositoryRootURL: repositoryRootURL,
127127+ tintColor: repositoryColor,
128128+ size: 12
129129+ )
130130+ }
112131 Text(repositoryName)
113132 .font(.caption.bold())
114133 .lineLimit(1)
···178197179198 @ViewBuilder
180199 private var titleBarBackground: some View {
200200+ // Layering, back-to-front:
201201+ //
202202+ // 1. selected-but-unfocused accent (subtle, sits under the bar
203203+ // material — same behavior as before this feature shipped)
204204+ // 2. `.bar` material substrate
205205+ // 3. **either** the notification orange **or** the repo color
206206+ // identity strip — never both. Without this mutual exclusion
207207+ // the orange used to muddle into the repo color (e.g. a blue
208208+ // repo's notification looked brownish-grey instead of the
209209+ // intended attention-grabbing orange). The notification wins
210210+ // on top with a much higher alpha (0.55) than the previous
211211+ // under-bar 0.3 so the unread signal actually pops.
181212 ZStack {
182182- if hasUnseenNotification {
183183- Color.orange.opacity(0.3)
184184- }
185213 if isSelected && !isFocused {
186214 Color.accentColor.opacity(0.12)
187215 }
188216 Rectangle()
189217 .fill(.bar)
190218 .opacity(0.9)
219219+ if hasUnseenNotification {
220220+ Color.orange.opacity(0.55)
221221+ } else if let repositoryColor {
222222+ repositoryColor.opacity(isFocused ? 0.18 : 0.10)
223223+ }
191224 }
192225 }
193226
+19
supacode/Features/Canvas/Views/CanvasView.swift
···11import AppKit
22+import Sharing
23import SwiftUI
3445struct CanvasView: View {
···89 let terminalManager: WorktreeTerminalManager
910 var onExitToTab: () -> Void = {}
1011 @State private var layoutStore = CanvasLayoutStore()
1212+ @Shared(.repositoryAppearances) private var repositoryAppearances
11131214 @State private var canvasOffset: CGSize = .zero
1315 @State private var lastCanvasOffset: CGSize = .zero
···8284 let screenCenter = screenPosition(for: resized.center)
8385 let cardTotalHeight = resized.size.height + titleBarHeight
84868787+ let repositoryAppearance = appearance(for: state.repositoryRootURL)
8588 CanvasCardView(
8689 repositoryName: Repository.name(for: state.repositoryRootURL),
8790 worktreeName: tab.title,
9191+ repositoryIcon: repositoryAppearance.icon,
9292+ repositoryColor: repositoryAppearance.color?.color,
9393+ repositoryRootURL: state.repositoryRootURL,
8894 tree: tree,
8995 isFocused: selectionState.primaryTabID == tab.id,
9096 isSelected: selectionState.selectedTabIDs.contains(tab.id),
···766772 // WorktreeTerminalTabsView.onAppear's syncFocus() and cause blank
767773 // surfaces. Cleanup of non-selected worktrees is handled by
768774 // setSelectedWorktreeID in the async exit flow.
775775+ }
776776+777777+ /// Looks up the user-pinned `RepositoryAppearance` for a given repo
778778+ /// root URL by deriving the canonical `Repository.ID` (the
779779+ /// path-policy-normalized path string) and querying the @Shared
780780+ /// dict. Returns `.empty` when no entry exists, which keeps cards
781781+ /// visually identical to before the appearance feature shipped.
782782+ private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance {
783783+ let id =
784784+ PathPolicy.normalizePath(
785785+ repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true
786786+ ) ?? repositoryRootURL.path(percentEncoded: false)
787787+ return repositoryAppearances[id] ?? .empty
769788 }
770789}
771790
···11+import AppKit
22+import SwiftUI
33+44+/// Single source of truth for rendering a `RepositoryIconSource` —
55+/// shared by the settings preview, the sidebar row, the shelf spine
66+/// header, and the canvas card title bar so tinting / fallback rules
77+/// stay consistent in one place.
88+///
99+/// Tinting follows `RepositoryIconSource.isTintable`: SF Symbols and
1010+/// SVG user images pick up `tintColor`; PNG user images and bundled
1111+/// assets ignore it. Missing user images fall back to a neutral SF
1212+/// Symbol so a deleted file on disk doesn't turn into a blank slot.
1313+struct RepositoryIconImage: View {
1414+ let icon: RepositoryIconSource
1515+ let repositoryRootURL: URL
1616+ /// Color used for tintable artwork. Pass `nil` to keep the
1717+ /// renderer's natural foreground (`.primary` / template default).
1818+ let tintColor: Color?
1919+ /// Logical pixel size of the icon. Affects `Image` sizing for asset
2020+ /// and user-image cases; SF Symbols size off the surrounding font.
2121+ let size: CGFloat
2222+2323+ init(
2424+ icon: RepositoryIconSource,
2525+ repositoryRootURL: URL,
2626+ tintColor: Color? = nil,
2727+ size: CGFloat = 16
2828+ ) {
2929+ self.icon = icon
3030+ self.repositoryRootURL = repositoryRootURL
3131+ self.tintColor = tintColor
3232+ self.size = size
3333+ }
3434+3535+ var body: some View {
3636+ content
3737+ .frame(width: size, height: size)
3838+ .accessibilityHidden(true)
3939+ }
4040+4141+ @ViewBuilder
4242+ private var content: some View {
4343+ switch icon {
4444+ case .sfSymbol(let name):
4545+ Image(systemName: name)
4646+ .resizable()
4747+ .aspectRatio(contentMode: .fit)
4848+ .symbolRenderingMode(.monochrome)
4949+ .foregroundStyle(resolvedTint)
5050+ .accessibilityHidden(true)
5151+ case .bundledAsset(let assetName):
5252+ Image(assetName)
5353+ .resizable()
5454+ .aspectRatio(contentMode: .fit)
5555+ .accessibilityHidden(true)
5656+ case .userImage(let filename):
5757+ userImage(filename: filename)
5858+ }
5959+ }
6060+6161+ @ViewBuilder
6262+ private func userImage(filename: String) -> some View {
6363+ let url = SupacodePaths.repositoryIconFileURL(
6464+ filename: filename, repositoryRootURL: repositoryRootURL
6565+ )
6666+ if let nsImage = Self.loadImage(at: url, asTemplate: icon.isTintable) {
6767+ if icon.isTintable {
6868+ Image(nsImage: nsImage)
6969+ .resizable()
7070+ .aspectRatio(contentMode: .fit)
7171+ .foregroundStyle(resolvedTint)
7272+ .accessibilityHidden(true)
7373+ } else {
7474+ Image(nsImage: nsImage)
7575+ .resizable()
7676+ .aspectRatio(contentMode: .fit)
7777+ .accessibilityHidden(true)
7878+ }
7979+ } else {
8080+ // The icon file was renamed/deleted out from under us. Show a
8181+ // muted placeholder rather than an empty rect so the bug is
8282+ // visible.
8383+ Image(systemName: "questionmark.square.dashed")
8484+ .resizable()
8585+ .aspectRatio(contentMode: .fit)
8686+ .foregroundStyle(.tertiary)
8787+ .accessibilityHidden(true)
8888+ }
8989+ }
9090+9191+ /// Loads an NSImage from disk and optionally flips it into template
9292+ /// mode so SwiftUI's `.foregroundStyle` can recolor it. Pulled out
9393+ /// of the ViewBuilder body so the side-effecting assignment doesn't
9494+ /// trip the builder's "type '()' cannot conform to 'View'" check.
9595+ private static func loadImage(at url: URL, asTemplate: Bool) -> NSImage? {
9696+ guard let image = NSImage(contentsOf: url) else { return nil }
9797+ image.isTemplate = asTemplate
9898+ return image
9999+ }
100100+101101+ private var resolvedTint: AnyShapeStyle {
102102+ if let tintColor {
103103+ AnyShapeStyle(tintColor)
104104+ } else {
105105+ AnyShapeStyle(.primary)
106106+ }
107107+ }
108108+}
···6666 let exampleWorktreePath = store.exampleWorktreePath
67676868 Form {
6969+ Section {
7070+ RepositoryAppearancePickerView(store: store)
7171+ } header: {
7272+ VStack(alignment: .leading, spacing: 4) {
7373+ Text("Appearance")
7474+ Text(
7575+ "Pick an icon and color to make this repository easy to spot in the sidebar, shelf, and canvas."
7676+ )
7777+ .foregroundStyle(.secondary)
7878+ }
7979+ }
8080+6981 if store.showsWorktreeSettings {
7082 Section {
7183 if store.isBranchDataLoaded {
···11+import Sharing
12import SwiftUI
2334/// Vertical spine rendering for a single book on the Shelf.
···3132 /// enum into this view.
3233 let closeMenuTitle: String
3334 let onCloseBook: (() -> Void)?
3535+ /// "Repo Settings" — opens the per-repo Settings tab. Always
3636+ /// available regardless of book kind since every book belongs to a
3737+ /// repository.
3838+ let onOpenRepositorySettings: () -> Void
34393540 @State private var isHovering = false
4141+ @Shared(.repositoryAppearances) private var repositoryAppearances
36423743 var body: some View {
3844 VStack(spacing: 0) {
···98104 /// bumps its tint to 80% of the selected book's intensity — a clear
99105 /// "this is interactable" affordance that sits just below the open
100106 /// book and animates in/out smoothly.
107107+ ///
108108+ /// When the book's repository has a user-pinned color, that color
109109+ /// replaces `Color.accentColor` as the proximity-tint base so the
110110+ /// shelf reads as "books on shelves" instead of one continuous
111111+ /// accent ribbon. The proximity ladder is unchanged — we only swap
112112+ /// the hue.
101113 private var spineBackgroundColor: Color {
102114 guard distanceFromOpen != nil else {
103115 return Color.primary.opacity(0.06)
104116 }
105117 let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier
106106- return Color.accentColor.opacity(0.20 * multiplier)
118118+ return effectiveTintColor.opacity(0.20 * multiplier)
119119+ }
120120+121121+ /// Repo's pinned color, or `.accentColor` when none — used as the
122122+ /// proximity-tint base and as the icon tint in the header.
123123+ private var effectiveTintColor: Color {
124124+ appearance.color?.color ?? .accentColor
125125+ }
126126+127127+ private var appearance: RepositoryAppearance {
128128+ repositoryAppearances[book.repositoryID] ?? .empty
107129 }
108130109131 /// Active-tab highlight fades more gently than the spine background —
···122144123145 @ViewBuilder
124146 private var bookContextMenu: some View {
147147+ Button {
148148+ onOpenRepositorySettings()
149149+ } label: {
150150+ Text("Repo Settings")
151151+ }
125152 if let onCloseBook {
153153+ Divider()
126154 Button {
127155 onCloseBook()
128156 } label: {
···164192 }
165193 }
166194 .padding(.horizontal, ShelfMetrics.slotHorizontalPadding)
195195+ .padding(.top, ShelfMetrics.sectionGap)
167196 .padding(.bottom, ShelfMetrics.slotSpacing)
168197 }
169198 }
···173202 Button(action: onOpenBook) {
174203 ShelfSpineHeader(
175204 book: book,
176176- hasAggregatedNotification: terminalState?.hasUnseenNotification == true
205205+ hasAggregatedNotification: terminalState?.hasUnseenNotification == true,
206206+ icon: appearance.icon,
207207+ iconTint: effectiveTintColor,
208208+ repositoryRootURL: URL(fileURLWithPath: book.repositoryID)
177209 )
178210 .frame(maxWidth: .infinity)
179211 .contentShape(.rect)
···217249 hotkeyIndex: hotkeyIndex,
218250 isActive: terminalState.tabManager.selectedTabId == tab.id,
219251 hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id),
252252+ activeHighlightTint: effectiveTintColor,
220253 activeHighlightAlpha: activeTabHighlightAlpha,
221254 onTap: { onSelectTab(tab.id) },
222255 onClose: { terminalState.closeTab(tab.id) }
···236269 }
237270 }
238271 .padding(.horizontal, ShelfMetrics.slotHorizontalPadding)
239239- .padding(.top, ShelfMetrics.slotSpacing)
272272+ .padding(.top, ShelfMetrics.sectionGap)
240273 }
241274242275}
···244277private struct ShelfSpineHeader: View {
245278 let book: ShelfBook
246279 let hasAggregatedNotification: Bool
280280+ let icon: RepositoryIconSource?
281281+ let iconTint: Color
282282+ let repositoryRootURL: URL
283283+284284+ /// Reserved slot for the top decoration (icon and/or notification),
285285+ /// sized at the maximum expected configuration (14pt icon plus a
286286+ /// 6pt badge nudged 3pt outward at the top-trailing corner).
287287+ /// Holding the slot at a constant size — whether or not an icon is
288288+ /// set — keeps every spine's header at the same total height so the
289289+ /// rotated titles align horizontally across the shelf row. When the
290290+ /// repo has no icon AND no notification, the slot is just empty
291291+ /// reserved space.
292292+ private let slotSize: CGFloat = 18
293293+ private let iconSize: CGFloat = 14
294294+ private let badgeSize: CGFloat = 6
295295+ private let badgeOffset: CGFloat = 3
247296248297 var body: some View {
249249- VStack(spacing: 6) {
250250- Circle()
251251- .fill(.orange)
252252- .frame(width: ShelfMetrics.aggregatedDotSize, height: ShelfMetrics.aggregatedDotSize)
253253- .opacity(hasAggregatedNotification ? 1 : 0)
254254- .accessibilityLabel("Unread notifications")
255255- .accessibilityHidden(!hasAggregatedNotification)
256256- .padding(.top, 6)
298298+ VStack(spacing: 8) {
299299+ slot
257300 rotatedTitle
258301 }
302302+ .padding(.top, 8)
303303+ }
304304+305305+ /// Three rendering paths driven by the (icon, notification) matrix:
306306+ /// - icon set: render the icon, hang the notification on it as a
307307+ /// small badge in the top-trailing corner (macOS app-icon style).
308308+ /// - no icon, has notification: fall back to the original
309309+ /// standalone orange dot, centered in the slot.
310310+ /// - no icon, no notification: slot stays empty but reserved.
311311+ @ViewBuilder
312312+ private var slot: some View {
313313+ ZStack {
314314+ Color.clear
315315+ .frame(width: slotSize, height: slotSize)
316316+317317+ if let icon {
318318+ RepositoryIconImage(
319319+ icon: icon,
320320+ repositoryRootURL: repositoryRootURL,
321321+ tintColor: iconTint,
322322+ size: iconSize
323323+ )
324324+ .overlay(alignment: .topTrailing) {
325325+ if hasAggregatedNotification {
326326+ notificationBadge
327327+ }
328328+ }
329329+ } else if hasAggregatedNotification {
330330+ Circle()
331331+ .fill(.orange)
332332+ .frame(
333333+ width: ShelfMetrics.aggregatedDotSize,
334334+ height: ShelfMetrics.aggregatedDotSize
335335+ )
336336+ }
337337+ }
338338+ .accessibilityElement()
339339+ .accessibilityLabel(hasAggregatedNotification ? "Unread notifications" : "")
340340+ .accessibilityHidden(!hasAggregatedNotification)
341341+ }
342342+343343+ /// Notification dot rendered as a corner badge over the icon. The
344344+ /// thin dark stroke keeps the orange visible on light spine
345345+ /// backgrounds; without it the badge would disappear on
346346+ /// orange-tinted repos.
347347+ @ViewBuilder
348348+ private var notificationBadge: some View {
349349+ Circle()
350350+ .fill(.orange)
351351+ .frame(width: badgeSize, height: badgeSize)
352352+ .overlay {
353353+ Circle().stroke(Color.black.opacity(0.25), lineWidth: 0.5)
354354+ }
355355+ .offset(x: badgeOffset, y: -badgeOffset)
259356 }
260357261358 /// Composed title rendered vertically (top-to-bottom reading direction).
···294391 let hotkeyIndex: Int?
295392 let isActive: Bool
296393 let hasUnseenNotification: Bool
394394+ /// Hue used for the active-tab background fill — repo color when
395395+ /// the owning book has one pinned, otherwise `Color.accentColor`.
396396+ /// Threaded from the spine so the active-tab indicator stays in
397397+ /// the same color family as the surrounding spine background
398398+ /// instead of clashing with a contrasting accent.
399399+ let activeHighlightTint: Color
297400 /// Absolute alpha for the active-tab accent fill, supplied by the
298401 /// enclosing spine so it can fade with proximity on its own curve
299402 /// (which decays more gently than the spine background — selection
···377480 .fill(Color.orange.opacity(0.3))
378481 } else if isActive {
379482 RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous)
380380- .fill(Color.accentColor.opacity(activeHighlightAlpha))
483483+ .fill(activeHighlightTint.opacity(activeHighlightAlpha))
381484 } else {
382485 Color.clear
383486 }
···418521 static let slotCornerRadius: CGFloat = 5
419522 static let slotSpacing: CGFloat = 3
420523 static let slotHorizontalPadding: CGFloat = 3
524524+ /// Vertical gap between major spine sections (header → tab list,
525525+ /// tab list → bottom controls). Larger than `slotSpacing` so the
526526+ /// rotated title doesn't crowd into the first tab and the last tab
527527+ /// doesn't crowd into the divider above the `+` button.
528528+ static let sectionGap: CGFloat = 10
421529 static let aggregatedDotSize: CGFloat = 6
422530 /// Max pre-rotation width (i.e. visual height after 90° rotation) of the
423531 /// spine header title. Texts longer than this get middle-truncated.
···11import ComposableArchitecture
22+import Dependencies
33+import DependenciesTestSupport
24import Foundation
55+import Sharing
36import Testing
4758@testable import supacode
···2629 $0.settings.selection = .repository(repository.id)
2730 $0.settings.repositorySettings = RepositorySettingsFeature.State(
2831 rootURL: repository.rootURL,
3232+ repositoryID: repository.id,
2933 repositoryKind: repository.kind,
3034 settings: .default,
3135 userSettings: .default
···7074 $0.settings.selection = .repository(repository.id)
7175 $0.settings.repositorySettings = RepositorySettingsFeature.State(
7276 rootURL: repository.rootURL,
7777+ repositoryID: repository.id,
7378 repositoryKind: .plain,
7479 settings: .default,
7580 userSettings: .default
7681 )
8282+ }
8383+ }
8484+8585+ @Test(.dependencies) func selectingRepositorySeedsAppearanceSynchronously() async {
8686+ // Regression: selecting a repo whose appearance is already in
8787+ // @Shared used to construct a State with `.empty` appearance and
8888+ // load asynchronously via .task. The async hop raced with the
8989+ // user's first click, sometimes wiping previously-saved fields.
9090+ // The State must now carry the appearance from frame zero.
9191+ let storage = SettingsTestStorage()
9292+ let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json")
9393+ let savedAppearance = RepositoryAppearance(
9494+ icon: .sfSymbol("hammer.fill"), color: .blue
9595+ )
9696+ let repository = Repository(
9797+ id: "appearance-repo",
9898+ rootURL: URL(fileURLWithPath: "/tmp/appearance-repo"),
9999+ name: "AppearanceRepo",
100100+ worktrees: []
101101+ )
102102+103103+ await withDependencies {
104104+ $0.settingsFileStorage = storage.storage
105105+ $0.repositoryAppearancesFileURL = appearancesURL
106106+ } operation: {
107107+ @Shared(.repositoryAppearances) var appearances
108108+ $appearances.withLock {
109109+ $0[repository.id] = savedAppearance
110110+ }
111111+112112+ let store = TestStore(
113113+ initialState: AppFeature.State(
114114+ repositories: RepositoriesFeature.State(repositories: [repository]),
115115+ settings: SettingsFeature.State()
116116+ )
117117+ ) {
118118+ AppFeature()
119119+ } withDependencies: {
120120+ $0.settingsFileStorage = storage.storage
121121+ $0.repositoryAppearancesFileURL = appearancesURL
122122+ }
123123+124124+ await store.send(.settings(.setSelection(.repository(repository.id)))) {
125125+ $0.settings.selection = .repository(repository.id)
126126+ $0.settings.repositorySettings = RepositorySettingsFeature.State(
127127+ rootURL: repository.rootURL,
128128+ repositoryID: repository.id,
129129+ repositoryKind: repository.kind,
130130+ settings: .default,
131131+ userSettings: .default,
132132+ appearance: savedAppearance
133133+ )
134134+ }
77135 }
78136 }
79137
+74
supacodeTests/RepositoryAppearanceTests.swift
···11+import Foundation
22+import Testing
33+44+@testable import supacode
55+66+struct RepositoryAppearanceTests {
77+ @Test func emptyHasBothNil() {
88+ #expect(RepositoryAppearance.empty.icon == nil)
99+ #expect(RepositoryAppearance.empty.color == nil)
1010+ #expect(RepositoryAppearance.empty.isEmpty)
1111+ }
1212+1313+ @Test func iconOnlyIsNotEmpty() {
1414+ let appearance = RepositoryAppearance(icon: .sfSymbol("folder"), color: nil)
1515+ #expect(!appearance.isEmpty)
1616+ }
1717+1818+ @Test func colorOnlyIsNotEmpty() {
1919+ let appearance = RepositoryAppearance(icon: nil, color: .blue)
2020+ #expect(!appearance.isEmpty)
2121+ }
2222+2323+ @Test func bothSetIsNotEmpty() {
2424+ let appearance = RepositoryAppearance(icon: .sfSymbol("folder"), color: .blue)
2525+ #expect(!appearance.isEmpty)
2626+ }
2727+2828+ @Test func codableRoundTripWithBoth() throws {
2929+ let original = RepositoryAppearance(
3030+ icon: .sfSymbol("folder.fill"),
3131+ color: .purple
3232+ )
3333+ let data = try JSONEncoder().encode(original)
3434+ let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data)
3535+ #expect(decoded == original)
3636+ }
3737+3838+ @Test func codableRoundTripIconOnly() throws {
3939+ let original = RepositoryAppearance(icon: .userImage(filename: "abc.svg"), color: nil)
4040+ let data = try JSONEncoder().encode(original)
4141+ let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data)
4242+ #expect(decoded == original)
4343+ }
4444+4545+ @Test func codableRoundTripColorOnly() throws {
4646+ let original = RepositoryAppearance(icon: nil, color: .green)
4747+ let data = try JSONEncoder().encode(original)
4848+ let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data)
4949+ #expect(decoded == original)
5050+ }
5151+5252+ @Test func codableRoundTripEmpty() throws {
5353+ let data = try JSONEncoder().encode(RepositoryAppearance.empty)
5454+ let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data)
5555+ #expect(decoded == .empty)
5656+ }
5757+5858+ @Test func decodingExtraFieldsIsTolerated() throws {
5959+ // Forward-compat: a future schema may add fields; older builds
6060+ // shouldn't refuse to decode.
6161+ let raw = Data(
6262+ """
6363+ {
6464+ "icon": "folder",
6565+ "color": "red",
6666+ "futureField": "ignored"
6767+ }
6868+ """.utf8
6969+ )
7070+ let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: raw)
7171+ #expect(decoded.icon == .sfSymbol("folder"))
7272+ #expect(decoded.color == .red)
7373+ }
7474+}
+124
supacodeTests/RepositoryAppearancesKeyTests.swift
···11+import Dependencies
22+import DependenciesTestSupport
33+import Foundation
44+import Sharing
55+import Testing
66+77+@testable import supacode
88+99+struct RepositoryAppearancesKeyTests {
1010+ @Test(.dependencies) func loadReturnsEmptyDictionaryWhenFileMissing() {
1111+ let storage = SettingsTestStorage()
1212+ let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json")
1313+1414+ let appearances: [Repository.ID: RepositoryAppearance] = withDependencies {
1515+ $0.settingsFileStorage = storage.storage
1616+ $0.repositoryAppearancesFileURL = url
1717+ } operation: {
1818+ @Shared(.repositoryAppearances) var appearances
1919+ return appearances
2020+ }
2121+2222+ #expect(appearances.isEmpty)
2323+ }
2424+2525+ @Test(.dependencies) func saveAndReloadRoundTrip() {
2626+ let storage = SettingsTestStorage()
2727+ let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json")
2828+2929+ withDependencies {
3030+ $0.settingsFileStorage = storage.storage
3131+ $0.repositoryAppearancesFileURL = url
3232+ } operation: {
3333+ @Shared(.repositoryAppearances) var appearances
3434+ $appearances.withLock {
3535+ $0["repo-1"] = RepositoryAppearance(icon: .sfSymbol("folder.fill"), color: .blue)
3636+ $0["repo-2"] = RepositoryAppearance(icon: nil, color: .purple)
3737+ }
3838+ }
3939+4040+ let reloaded: [Repository.ID: RepositoryAppearance] = withDependencies {
4141+ $0.settingsFileStorage = storage.storage
4242+ $0.repositoryAppearancesFileURL = url
4343+ } operation: {
4444+ @Shared(.repositoryAppearances) var appearances
4545+ return appearances
4646+ }
4747+4848+ #expect(
4949+ reloaded["repo-1"] == RepositoryAppearance(icon: .sfSymbol("folder.fill"), color: .blue)
5050+ )
5151+ #expect(reloaded["repo-2"] == RepositoryAppearance(icon: nil, color: .purple))
5252+ }
5353+5454+ @Test(.dependencies) func saveDropsEmptyEntries() {
5555+ // Clearing both icon and color resets a repo to the implicit
5656+ // "no appearance" state — we don't want to leave dead `{}` entries
5757+ // in the file forever.
5858+ let storage = SettingsTestStorage()
5959+ let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json")
6060+6161+ withDependencies {
6262+ $0.settingsFileStorage = storage.storage
6363+ $0.repositoryAppearancesFileURL = url
6464+ } operation: {
6565+ @Shared(.repositoryAppearances) var appearances
6666+ $appearances.withLock {
6767+ $0["keep"] = RepositoryAppearance(icon: .sfSymbol("folder"), color: nil)
6868+ $0["drop"] = .empty
6969+ }
7070+ }
7171+7272+ let reloaded: [Repository.ID: RepositoryAppearance] = withDependencies {
7373+ $0.settingsFileStorage = storage.storage
7474+ $0.repositoryAppearancesFileURL = url
7575+ } operation: {
7676+ @Shared(.repositoryAppearances) var appearances
7777+ return appearances
7878+ }
7979+8080+ #expect(reloaded["keep"] != nil)
8181+ #expect(reloaded["drop"] == nil)
8282+ }
8383+8484+ @Test(.dependencies) func loadIgnoresCorruptFile() {
8585+ let storage = SettingsTestStorage()
8686+ let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json")
8787+ try? storage.storage.save(Data("not-json".utf8), url)
8888+8989+ let appearances: [Repository.ID: RepositoryAppearance] = withDependencies {
9090+ $0.settingsFileStorage = storage.storage
9191+ $0.repositoryAppearancesFileURL = url
9292+ } operation: {
9393+ @Shared(.repositoryAppearances) var appearances
9494+ return appearances
9595+ }
9696+9797+ // Corrupt JSON should fall back to default (empty) rather than crash.
9898+ #expect(appearances.isEmpty)
9999+ }
100100+101101+ @Test(.dependencies) func savedJSONShape() throws {
102102+ // The on-disk shape is part of the public surface — pin it so a
103103+ // refactor of Codable defaults doesn't silently change the file
104104+ // format users have on disk.
105105+ let storage = SettingsTestStorage()
106106+ let url = URL(fileURLWithPath: "/tmp/repo-appearances-\(UUID().uuidString).json")
107107+108108+ withDependencies {
109109+ $0.settingsFileStorage = storage.storage
110110+ $0.repositoryAppearancesFileURL = url
111111+ } operation: {
112112+ @Shared(.repositoryAppearances) var appearances
113113+ $appearances.withLock {
114114+ $0["alpha"] = RepositoryAppearance(icon: .sfSymbol("folder"), color: .red)
115115+ }
116116+ }
117117+118118+ let data = try storage.storage.load(url)
119119+ let json = try JSONSerialization.jsonObject(with: data) as? [String: [String: String]]
120120+ let alpha = try #require(json?["alpha"])
121121+ #expect(alpha["icon"] == "folder")
122122+ #expect(alpha["color"] == "red")
123123+ }
124124+}
+50
supacodeTests/RepositoryColorChoiceTests.swift
···11+import Foundation
22+import Testing
33+44+@testable import supacode
55+66+struct RepositoryColorChoiceTests {
77+ @Test func paletteHasTenSystemColors() {
88+ // The fixed palette is part of the persistence contract: once
99+ // shipped, removing or renaming a case would break user JSON. This
1010+ // test pins the count so an accidental rename or removal trips a
1111+ // failure before release.
1212+ #expect(RepositoryColorChoice.allCases.count == 10)
1313+ }
1414+1515+ @Test func paletteCasesAreStable() {
1616+ // Raw values are written to JSON; reordering allCases is fine but
1717+ // case names are forever. Pin them.
1818+ let names = RepositoryColorChoice.allCases.map(\.rawValue).sorted()
1919+ #expect(
2020+ names == [
2121+ "blue",
2222+ "cyan",
2323+ "gray",
2424+ "green",
2525+ "mint",
2626+ "orange",
2727+ "pink",
2828+ "purple",
2929+ "red",
3030+ "yellow",
3131+ ]
3232+ )
3333+ }
3434+3535+ @Test func codableRoundTrip() throws {
3636+ let encoder = JSONEncoder()
3737+ let decoder = JSONDecoder()
3838+ for choice in RepositoryColorChoice.allCases {
3939+ let data = try encoder.encode(choice)
4040+ let decoded = try decoder.decode(RepositoryColorChoice.self, from: data)
4141+ #expect(decoded == choice)
4242+ }
4343+ }
4444+4545+ @Test func displayNameNonEmpty() {
4646+ for choice in RepositoryColorChoice.allCases {
4747+ #expect(!choice.displayName.isEmpty)
4848+ }
4949+ }
5050+}
+182
supacodeTests/RepositoryIconAssetStoreTests.swift
···11+import Foundation
22+import Testing
33+44+@testable import supacode
55+66+/// RAII helper: creates a unique scratch directory under `$TMPDIR` and
77+/// removes it on deinit so test runs don't accumulate orphan folders
88+/// between invocations (we ship to `make test` repeatedly during dev).
99+private final class ScratchDirectory {
1010+ let url: URL
1111+1212+ init(prefix: String) {
1313+ url = URL(fileURLWithPath: NSTemporaryDirectory())
1414+ .appending(path: "\(prefix)-\(UUID().uuidString)", directoryHint: .isDirectory)
1515+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
1616+ }
1717+1818+ deinit {
1919+ try? FileManager.default.removeItem(at: url)
2020+ }
2121+}
2222+2323+@MainActor
2424+struct RepositoryIconAssetStoreTests {
2525+ // MARK: - Helpers
2626+2727+ private func makeRepoRootScratch() -> ScratchDirectory {
2828+ ScratchDirectory(prefix: "prowl-icon-store")
2929+ }
3030+3131+ private func writeSourceFile(
3232+ in scratch: ScratchDirectory,
3333+ extension ext: String,
3434+ contents: Data = Data([0xDE, 0xAD])
3535+ ) throws -> URL {
3636+ let url = scratch.url.appending(path: "icon.\(ext)", directoryHint: .notDirectory)
3737+ try contents.write(to: url)
3838+ return url
3939+ }
4040+4141+ // MARK: - importImage
4242+4343+ @Test func importImageCopiesFileWithUUIDName() throws {
4444+ let store = RepositoryIconAssetStore.liveValue
4545+ let repoRoot = makeRepoRootScratch()
4646+ let source = ScratchDirectory(prefix: "prowl-icon-source")
4747+ let sourceFile = try writeSourceFile(
4848+ in: source, extension: "png", contents: Data([0x01, 0x02, 0x03])
4949+ )
5050+5151+ let filename = try store.importImage(sourceFile, repoRoot.url)
5252+5353+ #expect(filename.hasSuffix(".png"))
5454+ #expect(UUID(uuidString: String(filename.dropLast(4))) != nil)
5555+5656+ let resolved = SupacodePaths.repositoryIconFileURL(
5757+ filename: filename, repositoryRootURL: repoRoot.url
5858+ )
5959+ let copied = try Data(contentsOf: resolved)
6060+ #expect(copied == Data([0x01, 0x02, 0x03]))
6161+ }
6262+6363+ @Test func importImageNormalizesUppercaseExtension() throws {
6464+ let store = RepositoryIconAssetStore.liveValue
6565+ let repoRoot = makeRepoRootScratch()
6666+ let source = ScratchDirectory(prefix: "prowl-icon-source")
6767+ let sourceFile = try writeSourceFile(in: source, extension: "PNG")
6868+6969+ let filename = try store.importImage(sourceFile, repoRoot.url)
7070+ #expect(filename.hasSuffix(".png"))
7171+ }
7272+7373+ @Test func importImageAcceptsSVG() throws {
7474+ let store = RepositoryIconAssetStore.liveValue
7575+ let repoRoot = makeRepoRootScratch()
7676+ let source = ScratchDirectory(prefix: "prowl-icon-source")
7777+ let sourceFile = try writeSourceFile(in: source, extension: "svg")
7878+7979+ let filename = try store.importImage(sourceFile, repoRoot.url)
8080+ #expect(filename.hasSuffix(".svg"))
8181+ }
8282+8383+ @Test func importImageAcceptsArbitraryImageExtensions() throws {
8484+ // The store no longer enforces a PNG/SVG whitelist — the file
8585+ // picker filters down to image UTTypes already, and anything
8686+ // that NSImage can't decode falls back to a placeholder at
8787+ // render time. JPG / WebP / HEIC / GIF / TIFF / etc. all flow
8888+ // through the same byte-copy path and round-trip through
8989+ // `repositoryIconFileURL` like PNG does.
9090+ let store = RepositoryIconAssetStore.liveValue
9191+ let repoRoot = makeRepoRootScratch()
9292+9393+ for ext in ["jpg", "jpeg", "webp", "heic", "gif", "tiff", "bmp"] {
9494+ let source = ScratchDirectory(prefix: "prowl-icon-source")
9595+ let sourceFile = try writeSourceFile(in: source, extension: ext)
9696+ let filename = try store.importImage(sourceFile, repoRoot.url)
9797+ #expect(filename.hasSuffix(".\(ext)"))
9898+ }
9999+ }
100100+101101+ @Test func importImageHandlesFileWithNoExtension() throws {
102102+ // Defensive: a dragged-in file without an extension shouldn't
103103+ // crash the importer. The destination filename gets a generic
104104+ // fallback so the round-trip still works.
105105+ let store = RepositoryIconAssetStore.liveValue
106106+ let repoRoot = makeRepoRootScratch()
107107+ let source = ScratchDirectory(prefix: "prowl-icon-source")
108108+ let sourceFile = source.url.appending(path: "icon", directoryHint: .notDirectory)
109109+ try Data([0xDE, 0xAD]).write(to: sourceFile)
110110+111111+ let filename = try store.importImage(sourceFile, repoRoot.url)
112112+ #expect(!filename.isEmpty)
113113+ }
114114+115115+ @Test func importImageCreatesIconsDirectoryWhenMissing() throws {
116116+ let store = RepositoryIconAssetStore.liveValue
117117+ let repoRoot = makeRepoRootScratch()
118118+ // Don't pre-create the icons directory — importImage should make
119119+ // it itself, otherwise first-time imports would fail.
120120+ let source = ScratchDirectory(prefix: "prowl-icon-source")
121121+ let sourceFile = try writeSourceFile(in: source, extension: "png")
122122+123123+ _ = try store.importImage(sourceFile, repoRoot.url)
124124+125125+ let iconsDir = SupacodePaths.repositoryIconsDirectory(for: repoRoot.url)
126126+ var isDirectory: ObjCBool = false
127127+ let exists = FileManager.default.fileExists(
128128+ atPath: iconsDir.path(percentEncoded: false), isDirectory: &isDirectory
129129+ )
130130+ #expect(exists)
131131+ #expect(isDirectory.boolValue)
132132+ }
133133+134134+ @Test func importImageGeneratesUniqueFilenamePerCall() throws {
135135+ let store = RepositoryIconAssetStore.liveValue
136136+ let repoRoot = makeRepoRootScratch()
137137+ let source = ScratchDirectory(prefix: "prowl-icon-source")
138138+ let sourceFile = try writeSourceFile(in: source, extension: "png")
139139+140140+ let first = try store.importImage(sourceFile, repoRoot.url)
141141+ let second = try store.importImage(sourceFile, repoRoot.url)
142142+ #expect(first != second)
143143+ }
144144+145145+ // MARK: - exists
146146+147147+ @Test func existsReportsFalseWhenMissing() {
148148+ let store = RepositoryIconAssetStore.liveValue
149149+ let repoRoot = makeRepoRootScratch()
150150+ #expect(!store.exists("nonexistent.png", repoRoot.url))
151151+ }
152152+153153+ @Test func existsReportsTrueAfterImport() throws {
154154+ let store = RepositoryIconAssetStore.liveValue
155155+ let repoRoot = makeRepoRootScratch()
156156+ let source = ScratchDirectory(prefix: "prowl-icon-source")
157157+ let sourceFile = try writeSourceFile(in: source, extension: "png")
158158+ let filename = try store.importImage(sourceFile, repoRoot.url)
159159+ #expect(store.exists(filename, repoRoot.url))
160160+ }
161161+162162+ // MARK: - remove
163163+164164+ @Test func removeDeletesImportedFile() throws {
165165+ let store = RepositoryIconAssetStore.liveValue
166166+ let repoRoot = makeRepoRootScratch()
167167+ let source = ScratchDirectory(prefix: "prowl-icon-source")
168168+ let sourceFile = try writeSourceFile(in: source, extension: "png")
169169+ let filename = try store.importImage(sourceFile, repoRoot.url)
170170+171171+ try store.remove(filename, repoRoot.url)
172172+ #expect(!store.exists(filename, repoRoot.url))
173173+ }
174174+175175+ @Test func removeIsIdempotent() throws {
176176+ // Reset / replace flows can call remove repeatedly; missing files
177177+ // shouldn't throw or the reducer would have to track existence.
178178+ let store = RepositoryIconAssetStore.liveValue
179179+ let repoRoot = makeRepoRootScratch()
180180+ try store.remove("never-existed.png", repoRoot.url)
181181+ }
182182+}
+37
supacodeTests/RepositoryIconPresetsTests.swift
···11+import AppKit
22+import Testing
33+44+@testable import supacode
55+66+struct RepositoryIconPresetsTests {
77+ @Test func presetsCountIsForty() {
88+ // Picker grid is 8 columns wide and we want full rows. Pinning
99+ // the count here catches accidental drops or duplicates during
1010+ // refactors.
1111+ #expect(RepositoryIconPresets.presets.count == 40)
1212+ }
1313+1414+ @Test func presetsAreUnique() {
1515+ let unique = Set(RepositoryIconPresets.presets)
1616+ #expect(unique.count == RepositoryIconPresets.presets.count)
1717+ }
1818+1919+ @Test func presetsHaveNoEmptyEntries() {
2020+ for symbol in RepositoryIconPresets.presets {
2121+ #expect(!symbol.isEmpty)
2222+ }
2323+ }
2424+2525+ @Test func everyPresetResolvesToARealSFSymbolOnThisOS() {
2626+ // Catches a regression where a preset is added with a name like
2727+ // `rocket.fill` that *looks* plausible but doesn't actually exist
2828+ // (only `rocket` does, no `.fill` variant). A missing symbol
2929+ // would render as a blank tile in the picker grid — invisible
3030+ // bug. Run on the test machine's macOS, which is at least the
3131+ // project's minimum (macOS 26+ per CLAUDE.md).
3232+ let missing =
3333+ RepositoryIconPresets.presets
3434+ .filter { NSImage(systemSymbolName: $0, accessibilityDescription: nil) == nil }
3535+ #expect(missing.isEmpty, "Unrecognized SF Symbols in presets: \(missing)")
3636+ }
3737+}