native macOS codings agent orchestrator
5
fork

Configure Feed

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

fix(repo-appearance): seed appearance synchronously and curate presets

Two user-facing bugs and one tooling-quality fix.

**Bug 1+2 — appearance race on Settings reopen.**
Previously appearance was loaded from `@Shared(.repositoryAppearances)`
inside `RepositorySettingsFeature.task`, which sits behind an async
`gitClient.isBareRepository` await for git repos. The Settings UI
rendered with `.empty` appearance during that window, so:
- clicking a color before the load completed wrote `{icon: nil,
color: x}` and wiped the previously-saved icon (Bug 1)
- the color picker briefly showed "No color" selected for a repo
that did have a saved color (Bug 2)

Fix: read `@Shared(.repositoryAppearances)[repository.id]` synchronously
in `AppFeature` when constructing the `RepositorySettingsFeature.State`
— the same pattern settings/userSettings already use. The State is
correct from frame zero, eliminating the race. Removed the redundant
`appearanceLoaded` send from `.task` since there's no scenario where
appearance changes externally to this Settings window (unlike settings,
which can be touched from other windows during the git probe).

Two new regression tests pin the behavior:
- `selectingRepositorySeedsAppearanceSynchronously` (AppFeature) —
selecting a repo whose appearance is in @Shared produces a State
with that appearance, no subsequent action required.
- `pickingColorKeepsExistingIcon` / `pickingIconKeepsExistingColor`
(Reducer) — partial mutations preserve the other field.

**Bug 3 — `rocket.fill` rendered as a blank tile.**
SF Symbols ships `rocket` but no `.fill` variant; the picker showed an
invisible cell. Curate the preset list to symbols verified available
since SF Symbols 1–2 (macOS 11–12) baseline. New
`RepositoryIconPresetsTests` runs every entry through
`NSImage(systemSymbolName:)` so a future bad name is caught at CI
rather than in the picker.

**Bug 4 — picker had 36 entries, leaving the bottom row half-empty.**
Curated list now has exactly 40, organized: folders/containers (8) →
docs/books (6) → tags/markers (3) → tools/dev (8) → network (4) →
art (2) → nature/symbols (9). Pinned with a count test.

+171 -29
+38 -19
supacode/Domain/RepositoryIconPresets.swift
··· 6 6 /// picker ships. The split exists because the same picker is used in 7 7 /// two contexts that reach for different vocabulary: a tab picker 8 8 /// favors `play.fill` / `terminal` / `ladybug.fill`, a repo picker 9 - /// favors `folder.fill` / `cube.fill` / `book.fill`. 9 + /// favors `folder.fill` / `book.fill` / `hammer.fill`. 10 10 /// 11 11 /// Order is loosely thematic so scanning the grid surfaces intent: 12 - /// folders → boxes/data → tools → web/server → tech → ornament. 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). 13 21 nonisolated enum RepositoryIconPresets { 14 22 static let presets: [String] = [ 23 + // Folders / containers (8) 15 24 "folder.fill", 16 25 "folder", 17 - "folder.badge.gearshape", 26 + "folder.badge.plus", 27 + "tray.fill", 18 28 "tray.full.fill", 19 - "tray.2.fill", 20 29 "shippingbox.fill", 21 - "cube.fill", 22 - "cube.transparent", 23 - "doc.text.fill", 30 + "archivebox.fill", 31 + "externaldrive.fill", 32 + // Docs / books (6) 24 33 "doc.fill", 34 + "doc.text.fill", 35 + "doc.richtext.fill", 25 36 "book.fill", 26 37 "books.vertical.fill", 38 + "bookmark.fill", 39 + // Tags / markers (3) 40 + "tag.fill", 41 + "flag.fill", 42 + "paperplane.fill", 43 + // Tools / dev (8) 27 44 "hammer.fill", 45 + "wrench.fill", 28 46 "wrench.and.screwdriver.fill", 29 47 "screwdriver.fill", 30 - "paintpalette.fill", 31 - "paintbrush.fill", 48 + "gearshape.fill", 49 + "gear", 50 + "cpu", 51 + "ladybug.fill", 52 + // Network / web (4) 32 53 "globe", 33 54 "network", 34 55 "server.rack", 35 56 "cloud.fill", 36 - "cpu", 37 - "gearshape.fill", 38 - "swift", 39 - "ladybug.fill", 40 - "leaf.fill", 57 + // Art (2) 58 + "paintpalette.fill", 59 + "paintbrush.fill", 60 + // Nature / symbols (9) 41 61 "star.fill", 42 62 "heart.fill", 63 + "leaf.fill", 43 64 "bolt.fill", 44 65 "sparkles", 45 66 "flame.fill", 46 - "rocket.fill", 47 - "tag.fill", 48 - "bookmark.fill", 49 - "flag.fill", 50 - "circle.hexagongrid.fill", 67 + "sun.max.fill", 68 + "moon.fill", 69 + "envelope.fill", 51 70 ] 52 71 }
+3 -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, 396 397 repositoryID: repository.id, 397 398 repositoryKind: repository.kind, 398 399 settings: repositorySettings, 399 - userSettings: userRepositorySettings 400 + userSettings: userRepositorySettings, 401 + appearance: repositoryAppearances[repository.id] ?? .empty 400 402 ) 401 403 repoSettingsState.globalCopyIgnoredOnWorktreeCreate = state.settings.copyIgnoredOnWorktreeCreate 402 404 repoSettingsState.globalCopyUntrackedOnWorktreeCreate = state.settings.copyUntrackedOnWorktreeCreate
-9
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 107 107 switch action { 108 108 case .task: 109 109 let rootURL = state.rootURL 110 - let repositoryID = state.repositoryID 111 110 guard state.capabilities.supportsRepositoryGitSettings else { 112 111 return .run { send in 113 112 @Shared(.repositorySettings(rootURL)) var repositorySettings 114 113 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 115 114 @Shared(.settingsFile) var settingsFile 116 - @Shared(.repositoryAppearances) var appearances 117 115 let global = settingsFile.global 118 116 await send( 119 117 .settingsLoaded( ··· 127 125 keybindingUserOverrides: global.keybindingUserOverrides 128 126 ) 129 127 ) 130 - if let appearance = appearances[repositoryID], !appearance.isEmpty { 131 - await send(.appearanceLoaded(appearance)) 132 - } 133 128 } 134 129 } 135 130 let gitClient = gitClient ··· 138 133 @Shared(.repositorySettings(rootURL)) var repositorySettings 139 134 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 140 135 @Shared(.settingsFile) var settingsFile 141 - @Shared(.repositoryAppearances) var appearances 142 136 let global = settingsFile.global 143 137 await send( 144 138 .settingsLoaded( ··· 152 146 keybindingUserOverrides: global.keybindingUserOverrides 153 147 ) 154 148 ) 155 - if let appearance = appearances[repositoryID], !appearance.isEmpty { 156 - await send(.appearanceLoaded(appearance)) 157 - } 158 149 let branches: [String] 159 150 do { 160 151 branches = try await gitClient.branchRefs(rootURL)
+56
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 ··· 76 79 settings: .default, 77 80 userSettings: .default 78 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 + } 79 135 } 80 136 } 81 137
+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 + }
+37
supacodeTests/RepositorySettingsAppearanceTests.swift
··· 268 268 } 269 269 } 270 270 271 + // MARK: - Regression 272 + 273 + @Test func pickingColorKeepsExistingIcon() async throws { 274 + // Regression: appearance used to be loaded via `.task` async, which 275 + // raced with the first click after reopening Settings — picking a 276 + // color before `.task` finished would write `{icon: nil, color: x}` 277 + // and wipe the previously-saved icon. The fix seeds appearance 278 + // synchronously when the State is built, so the user's first click 279 + // sees the right baseline. This test pins the new behavior: with 280 + // an icon pre-set in initial state, setting a color must preserve 281 + // the icon. 282 + let store = makeStore( 283 + initialAppearance: RepositoryAppearance(icon: .sfSymbol("hammer.fill"), color: nil) 284 + ) 285 + 286 + await store.send(.setAppearanceColor(.blue)) { 287 + $0.appearance.color = .blue 288 + } 289 + 290 + #expect(store.state.appearance.icon == .sfSymbol("hammer.fill")) 291 + #expect(store.state.appearance.color == .blue) 292 + } 293 + 294 + @Test func pickingIconKeepsExistingColor() async throws { 295 + // Mirror of the above for the symmetric case. 296 + let store = makeStore( 297 + initialAppearance: RepositoryAppearance(icon: nil, color: .red) 298 + ) 299 + 300 + await store.send(.setAppearanceIcon(.sfSymbol("folder.fill"))) { 301 + $0.appearance.icon = .sfSymbol("folder.fill") 302 + } 303 + 304 + #expect(store.state.appearance.color == .red) 305 + #expect(store.state.appearance.icon == .sfSymbol("folder.fill")) 306 + } 307 + 271 308 // MARK: - Persistence helpers 272 309 273 310 private func readAppearances(