native macOS codings agent orchestrator
6
fork

Configure Feed

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

feat(repo-appearance): wire icon and color picker into repo settings

Surface the new RepositoryAppearance through the per-repo Settings UI
so users can actually pick an icon and a color. The plumbing is set up
to fan out to sidebar / shelf / canvas in the next phases — this commit
only changes the Settings tab itself.

- Parameterize TabIconPickerView with `presets`, `title`, `subtitle` so
the same picker code can serve both the tab and repo flows. Existing
callers stay unchanged thanks to defaulted parameters.
- Repository-flavored SF Symbol presets (folders / boxes / tools / web /
tech / ornament) live in `RepositoryIconPresets`, separate from the
terminal-themed tab list.
- New `RepositoryIconImage` is the single source of truth for rendering
any RepositoryIconSource — SF Symbol, bundled asset, or user PNG/SVG.
Tinting respects `isTintable` (PNG never tinted; SVG and SF Symbols
do); missing user images surface a dashed placeholder so a deleted
file is debuggable instead of invisible.
- `RepositoryAppearancePickerView` is the inline Settings section: icon
preview, "Choose Symbol…" sheet (TabIconPickerView with repo presets),
"Choose Image…" fileImporter (PNG/SVG only), "Clear Icon", and a
10-color palette plus an explicit "No color" swatch matching Finder.
- RepositorySettingsFeature now carries `appearance`, loads the entry
for `repositoryID` on `.task`, and routes mutations through explicit
actions (`setAppearanceColor`, `setAppearanceIcon`, `importUserImage`,
`resetAppearance`, `userImageImportFailed`) so the SwiftLint rule
against direct store mutation in views stays clean. Replacing or
clearing a `.userImage` icon cleans up the previous file via
`RepositoryIconAssetStore` so disk doesn't accumulate orphans.
- AppFeature passes `repository.id` so the canonical key is what hits
the @Shared dict.

Tests (RepositorySettingsAppearanceTests, 14 cases) cover persist /
clear / replace / reset paths, old-image cleanup semantics for every
icon transition, importer success and failure surfaces, and the
appearance-loaded init path. AppFeatureSettingsSelectionTests updated
to expect the new `repositoryID` field.

onevcat 2ed1eb3f a68eeea4

+892 -3
+52
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` / `cube.fill` / `book.fill`. 10 + /// 11 + /// Order is loosely thematic so scanning the grid surfaces intent: 12 + /// folders → boxes/data → tools → web/server → tech → ornament. 13 + nonisolated enum RepositoryIconPresets { 14 + static let presets: [String] = [ 15 + "folder.fill", 16 + "folder", 17 + "folder.badge.gearshape", 18 + "tray.full.fill", 19 + "tray.2.fill", 20 + "shippingbox.fill", 21 + "cube.fill", 22 + "cube.transparent", 23 + "doc.text.fill", 24 + "doc.fill", 25 + "book.fill", 26 + "books.vertical.fill", 27 + "hammer.fill", 28 + "wrench.and.screwdriver.fill", 29 + "screwdriver.fill", 30 + "paintpalette.fill", 31 + "paintbrush.fill", 32 + "globe", 33 + "network", 34 + "server.rack", 35 + "cloud.fill", 36 + "cpu", 37 + "gearshape.fill", 38 + "swift", 39 + "ladybug.fill", 40 + "leaf.fill", 41 + "star.fill", 42 + "heart.fill", 43 + "bolt.fill", 44 + "sparkles", 45 + "flame.fill", 46 + "rocket.fill", 47 + "tag.fill", 48 + "bookmark.fill", 49 + "flag.fill", 50 + "circle.hexagongrid.fill", 51 + ] 52 + }
+1
supacode/Features/App/Reducer/AppFeature.swift
··· 393 393 @Shared(.userRepositorySettings(repository.rootURL)) var userRepositorySettings 394 394 var repoSettingsState = RepositorySettingsFeature.State( 395 395 rootURL: repository.rootURL, 396 + repositoryID: repository.id, 396 397 repositoryKind: repository.kind, 397 398 settings: repositorySettings, 398 399 userSettings: userRepositorySettings
+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 + }
+127
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() ··· 91 107 switch action { 92 108 case .task: 93 109 let rootURL = state.rootURL 110 + let repositoryID = state.repositoryID 94 111 guard state.capabilities.supportsRepositoryGitSettings else { 95 112 return .run { send in 96 113 @Shared(.repositorySettings(rootURL)) var repositorySettings 97 114 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 98 115 @Shared(.settingsFile) var settingsFile 116 + @Shared(.repositoryAppearances) var appearances 99 117 let global = settingsFile.global 100 118 await send( 101 119 .settingsLoaded( ··· 109 127 keybindingUserOverrides: global.keybindingUserOverrides 110 128 ) 111 129 ) 130 + if let appearance = appearances[repositoryID], !appearance.isEmpty { 131 + await send(.appearanceLoaded(appearance)) 132 + } 112 133 } 113 134 } 114 135 let gitClient = gitClient ··· 117 138 @Shared(.repositorySettings(rootURL)) var repositorySettings 118 139 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 119 140 @Shared(.settingsFile) var settingsFile 141 + @Shared(.repositoryAppearances) var appearances 120 142 let global = settingsFile.global 121 143 await send( 122 144 .settingsLoaded( ··· 130 152 keybindingUserOverrides: global.keybindingUserOverrides 131 153 ) 132 154 ) 155 + if let appearance = appearances[repositoryID], !appearance.isEmpty { 156 + await send(.appearanceLoaded(appearance)) 157 + } 133 158 let branches: [String] 134 159 do { 135 160 branches = try await gitClient.branchRefs(rootURL) ··· 178 203 $repositorySettings.withLock { $0 = updatedSettings } 179 204 return .send(.delegate(.settingsChanged(rootURL))) 180 205 206 + case .appearanceLoaded(let appearance): 207 + state.appearance = appearance 208 + return .none 209 + 210 + case .setAppearanceColor(let color): 211 + guard state.appearance.color != color else { return .none } 212 + state.appearance.color = color 213 + return persistAppearance(state.appearance, repositoryID: state.repositoryID) 214 + 215 + case .setAppearanceIcon(let newIcon): 216 + let previousIcon = state.appearance.icon 217 + guard previousIcon != newIcon else { return .none } 218 + state.appearance.icon = newIcon 219 + let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) 220 + let cleanup = removeAbandonedUserImage( 221 + previous: previousIcon, 222 + new: newIcon, 223 + rootURL: state.rootURL 224 + ) 225 + return .merge(persist, cleanup) 226 + 227 + case .importUserImage(let sourceURL): 228 + let rootURL = state.rootURL 229 + let store = repositoryIconAssetStore 230 + return .run { send in 231 + do { 232 + let filename = try store.importImage(sourceURL, rootURL) 233 + await send(.userImageImported(filename: filename)) 234 + } catch let error as RepositoryIconAssetStoreError { 235 + await send(.userImageImportFailed(Self.errorMessage(for: error))) 236 + } catch { 237 + await send(.userImageImportFailed(error.localizedDescription)) 238 + } 239 + } 240 + 241 + case .userImageImported(let filename): 242 + return .send(.setAppearanceIcon(.userImage(filename: filename))) 243 + 244 + case .userImageImportFailed(let message): 245 + state.appearanceImportError = message 246 + return .none 247 + 248 + case .dismissAppearanceImportError: 249 + state.appearanceImportError = nil 250 + return .none 251 + 252 + case .resetAppearance: 253 + let previousIcon = state.appearance.icon 254 + guard !state.appearance.isEmpty else { return .none } 255 + state.appearance = .empty 256 + let persist = persistAppearance(.empty, repositoryID: state.repositoryID) 257 + let cleanup = removeAbandonedUserImage( 258 + previous: previousIcon, 259 + new: nil, 260 + rootURL: state.rootURL 261 + ) 262 + return .merge(persist, cleanup) 263 + 181 264 case .branchDataLoaded(let branches, let defaultBaseRef): 182 265 state.defaultWorktreeBaseRef = defaultBaseRef 183 266 var options = branches ··· 212 295 case .delegate: 213 296 return .none 214 297 } 298 + } 299 + } 300 + 301 + /// Writes the appearance back to the global `@Shared` dict, dropping 302 + /// the entry when it's been cleared so the on-disk file stays tight. 303 + private func persistAppearance( 304 + _ appearance: RepositoryAppearance, 305 + repositoryID: Repository.ID 306 + ) -> Effect<Action> { 307 + .run { _ in 308 + @Shared(.repositoryAppearances) var appearances 309 + $appearances.withLock { 310 + if appearance.isEmpty { 311 + $0.removeValue(forKey: repositoryID) 312 + } else { 313 + $0[repositoryID] = appearance 314 + } 315 + } 316 + } 317 + } 318 + 319 + /// When the icon transitions away from a user-imported file, the old 320 + /// asset on disk is no longer referenced and should be cleaned up. 321 + /// No-op when the previous icon wasn't a user image or when the new 322 + /// icon is the same user image. 323 + private func removeAbandonedUserImage( 324 + previous: RepositoryIconSource?, 325 + new: RepositoryIconSource?, 326 + rootURL: URL 327 + ) -> Effect<Action> { 328 + guard case .userImage(let oldFilename) = previous else { return .none } 329 + if case .userImage(let newFilename) = new, newFilename == oldFilename { 330 + return .none 331 + } 332 + let store = repositoryIconAssetStore 333 + return .run { _ in 334 + try? store.remove(oldFilename, rootURL) 335 + } 336 + } 337 + 338 + private static func errorMessage(for error: RepositoryIconAssetStoreError) -> String { 339 + switch error { 340 + case .unsupportedExtension(let ext): 341 + return "Repository icons must be PNG or SVG. \(ext.uppercased()) files aren't supported." 215 342 } 216 343 } 217 344 }
+270
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 + 23 + private let previewSize: CGFloat = 36 24 + private let dotSize: CGFloat = 22 25 + 26 + var body: some View { 27 + VStack(alignment: .leading, spacing: 12) { 28 + iconRow 29 + colorRow 30 + if let message = store.appearanceImportError { 31 + importErrorBanner(message: message) 32 + } 33 + } 34 + .sheet(isPresented: $isSymbolPickerPresented) { 35 + TabIconPickerView( 36 + initialIcon: currentSymbolName, 37 + defaultIcon: "folder.fill", 38 + title: "Repository Icon", 39 + subtitle: 40 + "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " 41 + + "with the repo color; PNG keeps its own colors.", 42 + presets: RepositoryIconPresets.presets, 43 + onApply: { applySymbolFromPicker($0) }, 44 + onCancel: { isSymbolPickerPresented = false } 45 + ) 46 + } 47 + .fileImporter( 48 + isPresented: $isImageImporterPresented, 49 + allowedContentTypes: [.png, .svg], 50 + allowsMultipleSelection: false 51 + ) { result in 52 + handleImageImportResult(result) 53 + } 54 + } 55 + 56 + // MARK: - Icon row 57 + 58 + @ViewBuilder 59 + private var iconRow: some View { 60 + HStack(alignment: .center, spacing: 12) { 61 + iconPreview 62 + VStack(alignment: .leading, spacing: 4) { 63 + Text("Icon") 64 + .font(.headline) 65 + Text(iconHelpText) 66 + .font(.caption) 67 + .foregroundStyle(.secondary) 68 + .fixedSize(horizontal: false, vertical: true) 69 + } 70 + Spacer(minLength: 8) 71 + iconButtons 72 + } 73 + } 74 + 75 + @ViewBuilder 76 + private var iconPreview: some View { 77 + let frame = RoundedRectangle(cornerRadius: 8, style: .continuous) 78 + let fill = Color.secondary.opacity(0.12) 79 + Group { 80 + if let icon = store.appearance.icon { 81 + RepositoryIconImage( 82 + icon: icon, 83 + repositoryRootURL: store.rootURL, 84 + tintColor: tintColor, 85 + size: 22 86 + ) 87 + } else { 88 + Image(systemName: "questionmark") 89 + .font(.system(size: 16, weight: .semibold)) 90 + .foregroundStyle(.tertiary) 91 + } 92 + } 93 + .frame(width: previewSize, height: previewSize) 94 + .background(fill, in: frame) 95 + .accessibilityLabel("Icon preview") 96 + } 97 + 98 + @ViewBuilder 99 + private var iconButtons: some View { 100 + HStack(spacing: 6) { 101 + Button("Choose Symbol…") { 102 + isSymbolPickerPresented = true 103 + } 104 + .help("Pick from a preset SF Symbol or enter any symbol name.") 105 + Button("Choose Image…") { 106 + isImageImporterPresented = true 107 + } 108 + .help("Import a PNG or SVG file as this repository's icon.") 109 + if store.appearance.icon != nil { 110 + Button("Clear Icon") { 111 + store.send(.setAppearanceIcon(nil)) 112 + } 113 + .help("Remove the current icon and stop showing one for this repo.") 114 + } 115 + } 116 + } 117 + 118 + private var iconHelpText: String { 119 + switch store.appearance.icon { 120 + case .userImage(let filename) where !filename.lowercased().hasSuffix(".svg"): 121 + return "PNG icons keep their original colors and ignore the repo color." 122 + case .userImage: 123 + return "User-provided SVGs are tinted with the repo color." 124 + case .sfSymbol: 125 + return "SF Symbols pick up the repo color when one is set." 126 + case .bundledAsset: 127 + return "Bundled icons keep their original artwork." 128 + case nil: 129 + return "No icon set — the row in the sidebar shows just the repo name." 130 + } 131 + } 132 + 133 + private var tintColor: Color { 134 + store.appearance.color?.color ?? .accentColor 135 + } 136 + 137 + private var currentSymbolName: String? { 138 + if case .sfSymbol(let name) = store.appearance.icon { 139 + return name 140 + } 141 + return nil 142 + } 143 + 144 + // MARK: - Color row 145 + 146 + @ViewBuilder 147 + private var colorRow: some View { 148 + VStack(alignment: .leading, spacing: 8) { 149 + Text("Color") 150 + .font(.headline) 151 + Text( 152 + "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." 153 + ) 154 + .font(.caption) 155 + .foregroundStyle(.secondary) 156 + .fixedSize(horizontal: false, vertical: true) 157 + HStack(spacing: 8) { 158 + ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in 159 + colorSwatch(for: choice) 160 + } 161 + noColorSwatch 162 + Spacer(minLength: 0) 163 + } 164 + } 165 + } 166 + 167 + @ViewBuilder 168 + private func colorSwatch(for choice: RepositoryColorChoice) -> some View { 169 + let isSelected = store.appearance.color == choice 170 + Button { 171 + store.send(.setAppearanceColor(choice)) 172 + } label: { 173 + Circle() 174 + .fill(choice.color) 175 + .frame(width: dotSize, height: dotSize) 176 + .overlay { 177 + Circle() 178 + .stroke(Color.primary, lineWidth: isSelected ? 2 : 0) 179 + .padding(2) 180 + } 181 + .help(choice.displayName) 182 + .accessibilityLabel(choice.displayName) 183 + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) 184 + } 185 + .buttonStyle(.plain) 186 + } 187 + 188 + @ViewBuilder 189 + private var noColorSwatch: some View { 190 + let isSelected = store.appearance.color == nil 191 + Button { 192 + store.send(.setAppearanceColor(nil)) 193 + } label: { 194 + Circle() 195 + .stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [2, 2])) 196 + .frame(width: dotSize, height: dotSize) 197 + .overlay { 198 + Image(systemName: "slash.circle") 199 + .font(.system(size: 12)) 200 + .foregroundStyle(.secondary) 201 + } 202 + .overlay { 203 + Circle() 204 + .stroke(Color.primary, lineWidth: isSelected ? 2 : 0) 205 + .padding(2) 206 + } 207 + .help("No color") 208 + .accessibilityLabel("No color") 209 + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) 210 + } 211 + .buttonStyle(.plain) 212 + } 213 + 214 + // MARK: - Error banner 215 + 216 + @ViewBuilder 217 + private func importErrorBanner(message: String) -> some View { 218 + HStack(spacing: 6) { 219 + Image(systemName: "exclamationmark.triangle.fill") 220 + .foregroundStyle(.orange) 221 + .accessibilityHidden(true) 222 + Text(message) 223 + .font(.caption) 224 + .foregroundStyle(.primary) 225 + Spacer(minLength: 0) 226 + Button("Dismiss") { 227 + store.send(.dismissAppearanceImportError) 228 + } 229 + .buttonStyle(.plain) 230 + .font(.caption) 231 + .foregroundStyle(.secondary) 232 + } 233 + .padding(.vertical, 4) 234 + .padding(.horizontal, 8) 235 + .background( 236 + RoundedRectangle(cornerRadius: 6, style: .continuous) 237 + .fill(Color.orange.opacity(0.12)) 238 + ) 239 + } 240 + 241 + // MARK: - Actions 242 + 243 + private func applySymbolFromPicker(_ name: String?) { 244 + isSymbolPickerPresented = false 245 + if let name { 246 + store.send(.setAppearanceIcon(.sfSymbol(name))) 247 + } else { 248 + store.send(.setAppearanceIcon(nil)) 249 + } 250 + } 251 + 252 + private func handleImageImportResult(_ result: Result<[URL], Error>) { 253 + isImageImporterPresented = false 254 + switch result { 255 + case .success(let urls): 256 + guard let url = urls.first else { return } 257 + // `fileImporter` returns security-scoped URLs on macOS — we need 258 + // to start access before reading and stop it on the way out so 259 + // the import store can copy the bytes into the sandboxed app 260 + // support directory. 261 + let needsScope = url.startAccessingSecurityScopedResource() 262 + defer { 263 + if needsScope { url.stopAccessingSecurityScopedResource() } 264 + } 265 + store.send(.importUserImage(url)) 266 + case .failure(let error): 267 + store.send(.userImageImportFailed(error.localizedDescription)) 268 + } 269 + } 270 + }
+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 {
+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
+2
supacodeTests/AppFeatureSettingsSelectionTests.swift
··· 26 26 $0.settings.selection = .repository(repository.id) 27 27 $0.settings.repositorySettings = RepositorySettingsFeature.State( 28 28 rootURL: repository.rootURL, 29 + repositoryID: repository.id, 29 30 repositoryKind: repository.kind, 30 31 settings: .default, 31 32 userSettings: .default ··· 70 71 $0.settings.selection = .repository(repository.id) 71 72 $0.settings.repositorySettings = RepositorySettingsFeature.State( 72 73 rootURL: repository.rootURL, 74 + repositoryID: repository.id, 73 75 repositoryKind: .plain, 74 76 settings: .default, 75 77 userSettings: .default
+308
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 importFailureSurfacesErrorMessage() async throws { 183 + let store = makeStore( 184 + iconAssetStore: RepositoryIconAssetStore( 185 + importImage: { _, _ in throw RepositoryIconAssetStoreError.unsupportedExtension("jpeg") }, 186 + remove: { _, _ in }, 187 + exists: { _, _ in false } 188 + ) 189 + ) 190 + 191 + await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.jpeg"))) 192 + await store.receive(\.userImageImportFailed) { 193 + $0.appearanceImportError = """ 194 + Repository icons must be PNG or SVG. JPEG files aren't supported. 195 + """.trimmingCharacters(in: .whitespacesAndNewlines) 196 + } 197 + await store.finish() 198 + } 199 + 200 + @Test func importGenericErrorSurfacesLocalizedDescription() async throws { 201 + struct Boom: LocalizedError { var errorDescription: String? { "boom" } } 202 + let store = makeStore( 203 + iconAssetStore: RepositoryIconAssetStore( 204 + importImage: { _, _ in throw Boom() }, 205 + remove: { _, _ in }, 206 + exists: { _, _ in false } 207 + ) 208 + ) 209 + 210 + await store.send(.importUserImage(URL(fileURLWithPath: "/tmp/source.svg"))) 211 + await store.receive(\.userImageImportFailed) { 212 + $0.appearanceImportError = "boom" 213 + } 214 + await store.finish() 215 + } 216 + 217 + @Test func dismissImportErrorClearsState() async throws { 218 + let store = makeStore() 219 + // Seed the error directly through the reducer surface. 220 + await store.send(.userImageImportFailed("boom")) { 221 + $0.appearanceImportError = "boom" 222 + } 223 + await store.send(.dismissAppearanceImportError) { 224 + $0.appearanceImportError = nil 225 + } 226 + } 227 + 228 + // MARK: - Reset 229 + 230 + @Test func resetAppearanceClearsBothAndRemovesUserImage() async throws { 231 + let removed = LockIsolated<[(String, URL)]>([]) 232 + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") 233 + let settingsStorage = SettingsTestStorage() 234 + let store = makeStore( 235 + initialAppearance: RepositoryAppearance( 236 + icon: .userImage(filename: "abc.png"), color: .blue 237 + ), 238 + iconAssetStore: .testRecording(removed: removed), 239 + appearancesURL: appearancesURL, 240 + settingsStorage: settingsStorage 241 + ) 242 + 243 + await store.send(.resetAppearance) { 244 + $0.appearance = .empty 245 + } 246 + await store.finish() 247 + 248 + #expect(removed.value.first?.0 == "abc.png") 249 + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) 250 + #expect(persisted["repo-1"] == nil) 251 + } 252 + 253 + @Test func resetAppearanceWhenAlreadyEmptyIsNoOp() async throws { 254 + let removed = LockIsolated<[(String, URL)]>([]) 255 + let store = makeStore(iconAssetStore: .testRecording(removed: removed)) 256 + await store.send(.resetAppearance) 257 + await store.finish() 258 + #expect(removed.value.isEmpty) 259 + } 260 + 261 + // MARK: - appearanceLoaded 262 + 263 + @Test func appearanceLoadedReplacesState() async throws { 264 + let store = makeStore() 265 + let loaded = RepositoryAppearance(icon: .sfSymbol("hammer"), color: .purple) 266 + await store.send(.appearanceLoaded(loaded)) { 267 + $0.appearance = loaded 268 + } 269 + } 270 + 271 + // MARK: - Persistence helpers 272 + 273 + private func readAppearances( 274 + at url: URL, storage: SettingsTestStorage 275 + ) -> [Repository.ID: RepositoryAppearance] { 276 + guard let data = try? storage.storage.load(url) else { return [:] } 277 + return (try? JSONDecoder().decode([Repository.ID: RepositoryAppearance].self, from: data)) 278 + ?? [:] 279 + } 280 + } 281 + 282 + // MARK: - Test fixtures 283 + 284 + extension RepositoryIconAssetStore { 285 + /// Silent test fixture: import always returns an empty filename and 286 + /// remove/exists are no-ops. Use when the test doesn't care about 287 + /// either side's effects. 288 + fileprivate static let testNoOp = RepositoryIconAssetStore( 289 + importImage: { _, _ in "" }, 290 + remove: { _, _ in }, 291 + exists: { _, _ in false } 292 + ) 293 + 294 + /// Test fixture that records every `remove` call so a test can 295 + /// assert on cleanup behavior without poking at the real 296 + /// filesystem. 297 + fileprivate static func testRecording(removed: LockIsolated<[(String, URL)]>) 298 + -> RepositoryIconAssetStore 299 + { 300 + RepositoryIconAssetStore( 301 + importImage: { _, _ in "" }, 302 + remove: { filename, root in 303 + removed.withValue { $0.append((filename, root)) } 304 + }, 305 + exists: { _, _ in false } 306 + ) 307 + } 308 + }