native macOS codings agent orchestrator
5
fork

Configure Feed

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

Surface custom repo title across shelf, canvas, toolbar, and settings via static state

Replaces the per-leaf @Shared(.repositorySettings(rootURL))
subscriptions from the previous attempt with a reducer-managed
dictionary on RepositoriesFeature.State. The old approach created a
fresh @Shared wrapper instance per call inside hot-path methods
(orderedShelfBooks, toolbarNotificationGroups, the canvas card
loop), which on cache miss triggered a settingsFile write that
notified all settingsFile subscribers, re-ran view bodies, re-built
the wrappers, and looped — pegging CPU at 90%+ on launch.

Now the flow is:

- RepositoriesFeature.State gains
`repositoryCustomTitles: [Repository.ID: String]`.
- Four new actions manage the dict: refreshAllCustomTitles
(full batch reload, runs in a reducer effect that's safe to read
@Shared from), refreshCustomTitle(URL) (single-repo refresh),
customTitlesLoaded(dict), customTitleUpdated(id, title?).
- AppFeature wires the triggers: it forwards
`.repositories(.delegate(.repositoriesChanged))` to
`.repositories(.refreshAllCustomTitles)`, and forwards
`.settings(.repositorySettings(.delegate(.settingsChanged(url))))`
to `.repositories(.refreshCustomTitle(url))`.
- All display sites read the dict statically:
- RepoDisplayName is now a pure-props view (no @Shared).
- Sidebar (RepoHeaderRow/RepositorySectionView), settings list,
settings detail nav title, and RepositoryDetailView take the
resolved custom title as a String parameter.
- ShelfBook.orderedShelfBooks and
toolbarNotificationGroups accept a customTitles dict; the shelf
spine, toolbar notifications popover, and canvas card all pass
`state.repositoryCustomTitles`.

Tests cover the dict-mutation actions
(customTitlesLoaded/customTitleUpdated state guards) and
parameterized model methods (orderedShelfBooks +
toolbarNotificationGroups override + fallback). The previous attempt's
hot-path tests are dropped — the methods are now pure with no
@Shared dependency to mock.

+309 -94
+9 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 335 335 var effects: [Effect<Action>] = [ 336 336 .send(.settings(.setSelection(.general))), 337 337 .send(.commandPalette(.pruneRecency(recencyIDs))), 338 + .send(.repositories(.refreshAllCustomTitles)), 338 339 .run { _ in 339 340 await terminalClient.send(.prune(ids)) 340 341 }, ··· 353 354 } 354 355 var effects: [Effect<Action>] = [ 355 356 .send(.commandPalette(.pruneRecency(recencyIDs))), 357 + .send(.repositories(.refreshAllCustomTitles)), 356 358 .run { _ in 357 359 await terminalClient.send(.prune(ids)) 358 360 }, ··· 787 789 } 788 790 789 791 case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL)))): 792 + // Always refresh the repo's custom title cache — display sites 793 + // (sidebar, shelf, canvas, toolbar, settings list) read it from 794 + // `RepositoriesFeature.State.repositoryCustomTitles` rather 795 + // than subscribing to the per-repo settings file directly. 796 + let refreshCustomTitle = Effect<Action>.send(.repositories(.refreshCustomTitle(rootURL))) 790 797 guard let selectedWorktree = state.repositories.selectedTerminalWorktree, 791 798 selectedWorktree.repositoryRootURL == rootURL 792 799 else { 793 - return .none 800 + return refreshCustomTitle 794 801 } 795 802 let worktreeID = selectedWorktree.id 796 803 @Shared(.repositorySettings(rootURL)) var repositorySettings 797 804 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 798 805 return .concatenate( 806 + refreshCustomTitle, 799 807 .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), 800 808 .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) 801 809 )
+26 -5
supacode/Features/Canvas/Views/CanvasView.swift
··· 7 7 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 8 8 9 9 let terminalManager: WorktreeTerminalManager 10 + /// Per-repo display titles resolved by the parent reducer. Used to 11 + /// override the folder-derived `Repository.name` on each card title 12 + /// bar without subscribing to per-repo settings files on the 13 + /// per-frame canvas hot path. 14 + var repositoryCustomTitles: [Repository.ID: String] = [:] 10 15 var onExitToTab: () -> Void = {} 11 16 @State private var layoutStore = CanvasLayoutStore() 12 17 @Shared(.repositoryAppearances) private var repositoryAppearances ··· 85 90 let cardTotalHeight = resized.size.height + titleBarHeight 86 91 87 92 let repositoryAppearance = appearance(for: state.repositoryRootURL) 93 + let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) 88 94 CanvasCardView( 89 - repositoryName: Repository.name(for: state.repositoryRootURL), 95 + repositoryName: resolvedRepositoryName, 90 96 worktreeName: tab.title, 91 97 repositoryIcon: repositoryAppearance.icon, 92 98 repositoryColor: repositoryAppearance.color?.color, ··· 780 786 /// dict. Returns `.empty` when no entry exists, which keeps cards 781 787 /// visually identical to before the appearance feature shipped. 782 788 private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { 783 - let id = 784 - PathPolicy.normalizePath( 785 - repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true 786 - ) ?? repositoryRootURL.path(percentEncoded: false) 789 + let id = repositoryID(for: repositoryRootURL) 787 790 return repositoryAppearances[id] ?? .empty 791 + } 792 + 793 + /// Resolves the user-defined display title for the repo at this root 794 + /// URL, falling back to `Repository.name(for:)` (folder name) when no 795 + /// custom title was set. Reads from the static dictionary populated 796 + /// by the parent reducer — no per-call `@Shared` subscription on the 797 + /// canvas hot path. 798 + private func repositoryDisplayName(for repositoryRootURL: URL) -> String { 799 + let id = repositoryID(for: repositoryRootURL) 800 + return repositoryCustomTitles[id] ?? Repository.name(for: repositoryRootURL) 801 + } 802 + 803 + /// Mirrors the same path normalization the `Repository.ID` is built 804 + /// from, so dict lookups match what the reducer stores. 805 + private func repositoryID(for repositoryRootURL: URL) -> Repository.ID { 806 + PathPolicy.normalizePath( 807 + repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true 808 + ) ?? repositoryRootURL.path(percentEncoded: false) 788 809 } 789 810 } 790 811
+6 -2
supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift
··· 26 26 } 27 27 28 28 extension RepositoriesFeature.State { 29 + /// `customTitles` is an optional per-repo display-name dictionary; 30 + /// when an entry exists the group's `name` uses it instead of 31 + /// `repository.name`. Defaults to empty for legacy callers/tests. 29 32 func toolbarNotificationGroups( 30 - terminalManager: WorktreeTerminalManager 33 + terminalManager: WorktreeTerminalManager, 34 + customTitles: [Repository.ID: String] = [:] 31 35 ) -> [ToolbarNotificationRepositoryGroup] { 32 36 let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 33 37 var groups: [ToolbarNotificationRepositoryGroup] = [] ··· 54 58 groups.append( 55 59 ToolbarNotificationRepositoryGroup( 56 60 id: repository.id, 57 - name: repository.name, 61 + name: customTitles[repository.id] ?? repository.name, 58 62 worktrees: worktreeGroups 59 63 ) 60 64 )
+57
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 187 187 var repositoryRoots: [URL] = [] 188 188 var repositoryOrderIDs: [Repository.ID] = [] 189 189 var loadFailuresByID: [Repository.ID: String] = [:] 190 + /// User-defined display titles indexed by `Repository.ID`. Resolved 191 + /// once on repo discovery (and refreshed when settings change) so 192 + /// hot-path display sites — sidebar, shelf spine, canvas card, 193 + /// toolbar notifications, settings list — read a plain dictionary 194 + /// instead of subscribing to `@Shared(.repositorySettings(...))` 195 + /// per row per frame. Absent entries fall back to `repository.name`. 196 + var repositoryCustomTitles: [Repository.ID: String] = [:] 190 197 var selection: SidebarSelection? 191 198 var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] 192 199 var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] ··· 276 283 case refreshWorktrees 277 284 case reloadRepositories(animated: Bool) 278 285 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) 286 + case refreshAllCustomTitles 287 + case refreshCustomTitle(URL) 288 + case customTitlesLoaded([Repository.ID: String]) 289 + case customTitleUpdated(Repository.ID, String?) 279 290 case codeHostsDetected([Repository.ID: CodeHost]) 280 291 case selectArchivedWorktrees 281 292 case selectCanvas ··· 631 642 allEffects.append(effect) 632 643 } 633 644 return .merge(allEffects) 645 + 646 + case .refreshAllCustomTitles: 647 + // Fan out across the current repository list, reading each 648 + // per-repo settings file via `@Shared`. Runs in a reducer 649 + // effect (not in a view body), so even when the first cache 650 + // miss triggers a `settingsFile` write the resulting view 651 + // re-render can't loop back into this action. 652 + let repositoriesForTitleRefresh = Array(state.repositories) 653 + return .run { send in 654 + var dict: [Repository.ID: String] = [:] 655 + for repository in repositoriesForTitleRefresh { 656 + @Shared(.repositorySettings(repository.rootURL)) var settings 657 + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) 658 + if let trimmed, !trimmed.isEmpty { 659 + dict[repository.id] = trimmed 660 + } 661 + } 662 + await send(.customTitlesLoaded(dict)) 663 + } 664 + 665 + case .refreshCustomTitle(let rootURL): 666 + guard let repository = state.repositories.first(where: { $0.rootURL == rootURL }) else { 667 + return .none 668 + } 669 + let repositoryID = repository.id 670 + return .run { send in 671 + @Shared(.repositorySettings(rootURL)) var settings 672 + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) 673 + let normalized = (trimmed?.isEmpty ?? true) ? nil : trimmed 674 + await send(.customTitleUpdated(repositoryID, normalized)) 675 + } 676 + 677 + case .customTitlesLoaded(let dict): 678 + guard state.repositoryCustomTitles != dict else { return .none } 679 + state.repositoryCustomTitles = dict 680 + return .none 681 + 682 + case .customTitleUpdated(let id, let title): 683 + if let title { 684 + guard state.repositoryCustomTitles[id] != title else { return .none } 685 + state.repositoryCustomTitles[id] = title 686 + } else { 687 + guard state.repositoryCustomTitles[id] != nil else { return .none } 688 + state.repositoryCustomTitles.removeValue(forKey: id) 689 + } 690 + return .none 634 691 635 692 case .codeHostsDetected(let codeHostByRepositoryID): 636 693 let knownIDs = Set(state.repositories.ids)
+10 -37
supacode/Features/Repositories/Views/RepoDisplayName.swift
··· 1 - import Sharing 2 1 import SwiftUI 3 2 4 - /// Renders the repository display label, preferring the user's custom 5 - /// title from `RepositorySettings` over the folder-derived fallback. 6 - /// 7 - /// Subscription is isolated to this leaf view so callers don't pull in 8 - /// `@Shared(.repositorySettings(...))` themselves and parent views 9 - /// don't churn on settings changes. Mirrors the per-leaf-subscription 10 - /// pattern used by `RepoHeaderTabCountBadge`. 3 + /// Renders the repository display label, preferring a user-defined 4 + /// `customTitle` over the folder-derived `fallbackName`. 11 5 /// 12 - /// The view emits a plain `Text` — callers apply their own font / 13 - /// foreground style modifiers so this view stays appearance-agnostic. 6 + /// Stateless on purpose: the source of truth for `customTitle` lives 7 + /// in `RepositoriesFeature.State.repositoryCustomTitles` (refreshed by 8 + /// the reducer when settings change), so display sites read a plain 9 + /// string and avoid per-row `@Shared(.repositorySettings(...))` 10 + /// subscriptions on the hot path. Callers apply their own font / 11 + /// foreground style modifiers — this view stays appearance-agnostic. 14 12 struct RepoDisplayName: View { 15 13 let fallbackName: String 16 - let repositoryRootURL: URL? 14 + var customTitle: String? 17 15 var tooltip: String? 18 16 19 17 var body: some View { 20 - if let repositoryRootURL { 21 - RepoDisplayNameResolved( 22 - rootURL: repositoryRootURL, 23 - fallbackName: fallbackName, 24 - tooltip: tooltip 25 - ) 26 - } else { 27 - Text(fallbackName) 28 - .help(tooltip ?? "") 29 - } 30 - } 31 - } 32 - 33 - private struct RepoDisplayNameResolved: View { 34 - let fallbackName: String 35 - let tooltip: String? 36 - @Shared private var settings: RepositorySettings 37 - 38 - init(rootURL: URL, fallbackName: String, tooltip: String?) { 39 - self.fallbackName = fallbackName 40 - self.tooltip = tooltip 41 - _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) 42 - } 43 - 44 - var body: some View { 45 - Text(settings.customTitle ?? fallbackName) 18 + Text(customTitle ?? fallbackName) 46 19 .help(tooltip ?? "") 47 20 } 48 21 }
+4 -1
supacode/Features/Repositories/Views/RepoHeaderRow.swift
··· 3 3 struct RepoHeaderRow: View { 4 4 private static let debugHeaderLayers = false 5 5 let name: String 6 + /// User-defined display title resolved by the parent reducer. When 7 + /// non-nil, takes precedence over `name` for display. 8 + var customTitle: String? 6 9 let isRemoving: Bool 7 10 /// User-pinned icon, when set. Renders before the repo name. 8 11 /// `nil` keeps the historical text-only layout intact. ··· 27 30 } 28 31 RepoDisplayName( 29 32 fallbackName: name, 30 - repositoryRootURL: repositoryRootURL, 33 + customTitle: customTitle, 31 34 tooltip: nameTooltip 32 35 ) 33 36 .foregroundStyle(.secondary)
+4 -1
supacode/Features/Repositories/Views/RepositoryDetailView.swift
··· 2 2 3 3 struct RepositoryDetailView: View { 4 4 let repository: Repository 5 + /// Resolved by the parent reducer. When non-nil, takes precedence 6 + /// over `repository.name` for display. 7 + var customTitle: String? 5 8 6 9 var body: some View { 7 10 VStack(spacing: 12) { ··· 10 13 .accessibilityHidden(true) 11 14 RepoDisplayName( 12 15 fallbackName: repository.name, 13 - repositoryRootURL: repository.rootURL 16 + customTitle: customTitle 14 17 ) 15 18 .font(.title3.weight(.semibold)) 16 19 Text(repository.rootURL.path(percentEncoded: false))
+1
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 46 46 HStack { 47 47 RepoHeaderRow( 48 48 name: repository.name, 49 + customTitle: store.repositoryCustomTitles[repository.id], 49 50 isRemoving: isRemovingRepository, 50 51 icon: appearance.icon, 51 52 iconTint: appearance.color?.color,
+10 -3
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 48 48 let runScriptEnabled = hasActiveTerminalTarget 49 49 let runScriptIsRunning = selectedTerminalWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true 50 50 let customCommands = state.selectedCustomCommands 51 - let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) 51 + let notificationGroups = repositories.toolbarNotificationGroups( 52 + terminalManager: terminalManager, 53 + customTitles: repositories.repositoryCustomTitles 54 + ) 52 55 let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in 53 56 count + repository.unseenWorktreeCount 54 57 } ··· 221 224 if repositories.isShowingCanvas { 222 225 CanvasView( 223 226 terminalManager: terminalManager, 227 + repositoryCustomTitles: repositories.repositoryCustomTitles, 224 228 onExitToTab: { 225 229 store.send(.repositories(.toggleCanvas)) 226 230 }) ··· 260 264 } 261 265 } 262 266 } else if let selectedRepository = repositories.selectedRepository { 263 - RepositoryDetailView(repository: selectedRepository) 267 + RepositoryDetailView( 268 + repository: selectedRepository, 269 + customTitle: repositories.repositoryCustomTitles[selectedRepository.id] 270 + ) 264 271 } else { 265 272 EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) 266 273 } ··· 911 918 key: "u", 912 919 modifiers: UserCustomShortcutModifiers() 913 920 ) 914 - ) 921 + ), 915 922 ], 916 923 isUpdateAvailable: true, 917 924 availableUpdateVersion: "2026.5.1"
+6 -32
supacode/Features/Settings/Views/SettingsView.swift
··· 1 1 import ComposableArchitecture 2 - import Sharing 3 2 import SwiftUI 4 3 5 4 extension View { ··· 25 24 var body: some View { 26 25 let updatesStore = store.scope(state: \.updates, action: \.updates) 27 26 let repositories = store.repositories.repositories 27 + let customTitles = store.repositories.repositoryCustomTitles 28 28 let selection = settingsStore.selection ?? .general 29 29 30 30 NavigationSplitView(columnVisibility: .constant(.all)) { ··· 49 49 ForEach(repositories) { repository in 50 50 RepoDisplayName( 51 51 fallbackName: repository.name, 52 - repositoryRootURL: repository.rootURL 52 + customTitle: customTitles[repository.id] 53 53 ) 54 54 .tag(SettingsSection.repository(repository.id)) 55 55 } ··· 110 110 IfLetStore( 111 111 settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) 112 112 ) { repositorySettingsStore in 113 - RepositorySettingsDetailContainer( 114 - store: repositorySettingsStore, 115 - repository: repository 116 - ) 113 + RepositorySettingsView(store: repositorySettingsStore) 114 + .id(repository.id) 115 + .navigationTitle(customTitles[repository.id] ?? repository.name) 116 + .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) 117 117 } 118 118 } 119 119 } else { ··· 136 136 .ignoresSafeArea(.container, edges: .top) 137 137 } 138 138 } 139 - 140 - /// Wraps `RepositorySettingsView` with a `@Shared` subscription on the 141 - /// repo's settings file so the navigation title can reflect the user's 142 - /// custom title (when set) instead of the folder-derived `Repository.name`. 143 - /// Lives here rather than inside `RepositorySettingsView` because the 144 - /// `.navigationTitle` modifier needs a `String`, not a view, and reading 145 - /// `@Shared` requires a struct property — wrapping at this layer keeps 146 - /// `RepositorySettingsView`'s interface untouched. 147 - private struct RepositorySettingsDetailContainer: View { 148 - let store: StoreOf<RepositorySettingsFeature> 149 - let repository: Repository 150 - @Shared private var settings: RepositorySettings 151 - 152 - init(store: StoreOf<RepositorySettingsFeature>, repository: Repository) { 153 - self.store = store 154 - self.repository = repository 155 - _settings = Shared(wrappedValue: .default, .repositorySettings(repository.rootURL)) 156 - } 157 - 158 - var body: some View { 159 - RepositorySettingsView(store: store) 160 - .id(repository.id) 161 - .navigationTitle(settings.customTitle ?? repository.name) 162 - .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) 163 - } 164 - }
+15 -5
supacode/Features/Shelf/Models/ShelfBook.swift
··· 38 38 /// Clicking a previously-unopened worktree in the left navigation 39 39 /// while in Shelf mode adds its ID here, which causes its spine to 40 40 /// materialize (with the standard spine-flow animation). 41 - func orderedShelfBooks() -> [ShelfBook] { 41 + /// Builds the ordered list of shelf books from current state. 42 + /// 43 + /// `customTitles` is an optional dictionary providing user-defined 44 + /// display names per repository. Defaults to empty for callers that 45 + /// don't care (e.g. legacy tests). The resolved `projectName` (and 46 + /// `displayName` for plain folders) prefers the custom title when 47 + /// present and falls back to `repository.name` otherwise. 48 + func orderedShelfBooks( 49 + customTitles: [Repository.ID: String] = [:] 50 + ) -> [ShelfBook] { 42 51 // `ShelfView.body` re-runs on every TCA state change, so this method 43 52 // is on the per-frame hot path. The previous implementation built a 44 53 // `Dictionary(uniqueKeysWithValues:)` per call and routed worktree ··· 51 60 var books: [ShelfBook] = [] 52 61 for repositoryID in orderedRepositoryIDs() { 53 62 guard let repository = repositories[id: repositoryID] else { continue } 63 + let projectName = customTitles[repositoryID] ?? repository.name 54 64 if repository.kind == .plain { 55 65 guard openedWorktreeIDs.contains(repository.id) else { continue } 56 66 books.append( 57 67 ShelfBook( 58 68 id: repository.id, 59 69 repositoryID: repository.id, 60 - displayName: repository.name, 61 - projectName: repository.name, 70 + displayName: projectName, 71 + projectName: projectName, 62 72 branchName: nil, 63 73 kind: .plainFolder 64 74 )) ··· 71 81 id: worktree.id, 72 82 repositoryID: repositoryID, 73 83 displayName: worktree.name, 74 - projectName: repository.name, 84 + projectName: projectName, 75 85 branchName: worktree.name, 76 86 kind: .worktree 77 87 )) ··· 90 100 id: pending.id, 91 101 repositoryID: repositoryID, 92 102 displayName: pending.progress.titleText, 93 - projectName: repository.name, 103 + projectName: projectName, 94 104 branchName: pending.progress.titleText, 95 105 kind: .worktree 96 106 ))
+1 -1
supacode/Features/Shelf/Views/ShelfView.swift
··· 31 31 // sanity-checking how often the root re-renders during animation. 32 32 let _ = shelfLogger.event("ShelfView.body") 33 33 let state = store.state 34 - let books = state.orderedShelfBooks() 34 + let books = state.orderedShelfBooks(customTitles: state.repositoryCustomTitles) 35 35 let openBookID = state.openShelfBookID 36 36 let openIndex = openBookID.flatMap { id in 37 37 books.firstIndex(where: { $0.id == id })
+47 -6
supacodeTests/RepositoriesFeatureTests.swift
··· 67 67 } 68 68 } 69 69 70 + @Test func customTitlesLoadedReplacesEntireDictionary() async { 71 + let store = TestStore(initialState: RepositoriesFeature.State()) { 72 + RepositoriesFeature() 73 + } 74 + 75 + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) { 76 + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] 77 + } 78 + 79 + // Re-sending the same dict is a no-op (state mutation guard avoids 80 + // gratuitous TCA-driven view refreshes). 81 + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) 82 + 83 + await store.send(.customTitlesLoaded(["repo-c": "Gamma"])) { 84 + $0.repositoryCustomTitles = ["repo-c": "Gamma"] 85 + } 86 + } 87 + 88 + @Test func customTitleUpdatedSetsAndRemovesSingleEntry() async { 89 + var initialState = RepositoriesFeature.State() 90 + initialState.repositoryCustomTitles = ["repo-a": "Alpha"] 91 + let store = TestStore(initialState: initialState) { 92 + RepositoriesFeature() 93 + } 94 + 95 + await store.send(.customTitleUpdated("repo-b", "Beta")) { 96 + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] 97 + } 98 + 99 + // Same value → no state change 100 + await store.send(.customTitleUpdated("repo-b", "Beta")) 101 + 102 + // nil removes the entry 103 + await store.send(.customTitleUpdated("repo-a", nil)) { 104 + $0.repositoryCustomTitles = ["repo-b": "Beta"] 105 + } 106 + 107 + // Removing a non-existent entry is a no-op 108 + await store.send(.customTitleUpdated("repo-a", nil)) 109 + } 110 + 70 111 @Test func updateWorktreeLineChangesReturnsFalseWhenCountsMatchExistingEntry() { 71 112 let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 72 113 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) ··· 622 663 [ 623 664 PersistedRepositoryEntry(path: repoRoot, kind: .git), 624 665 PersistedRepositoryEntry(path: plainRoot, kind: .plain), 625 - ] 666 + ], 626 667 ] 627 668 #expect(savedEntries.value == expectedSavedEntries) 628 669 } ··· 1953 1994 id: pendingID, 1954 1995 repositoryID: repository.id, 1955 1996 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 1956 - ) 1997 + ), 1957 1998 ] 1958 1999 let store = TestStore(initialState: state) { 1959 2000 RepositoriesFeature() ··· 1991 2032 stage: .checkingRepositoryMode, 1992 2033 worktreeName: "swift-otter" 1993 2034 ) 1994 - ) 2035 + ), 1995 2036 ] 1996 2037 let store = TestStore(initialState: state) { 1997 2038 RepositoriesFeature() ··· 2186 2227 addedLines: nil, 2187 2228 removedLines: nil, 2188 2229 pullRequest: makePullRequest(state: "MERGED") 2189 - ) 2230 + ), 2190 2231 ] 2191 2232 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 2192 2233 let store = TestStore(initialState: state) { ··· 2880 2921 id: removedWorktree.id, 2881 2922 repositoryID: repository.id, 2882 2923 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2883 - ) 2924 + ), 2884 2925 ] 2885 2926 initialState.pinnedWorktreeIDs = [removedWorktree.id] 2886 2927 initialState.worktreeInfoByID = [ ··· 2967 3008 id: pendingID, 2968 3009 repositoryID: repository.id, 2969 3010 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 2970 - ) 3011 + ), 2971 3012 ] 2972 3013 initialState.selection = .worktree(pendingID) 2973 3014 initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID]
+72
supacodeTests/ShelfBookOrderingTests.swift
··· 196 196 197 197 #expect(state.openShelfBookID == repository.id) 198 198 } 199 + 200 + @Test func customTitleOverridesProjectNameForWorktreeBooks() { 201 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 202 + let main = Worktree( 203 + id: "/tmp/repo", 204 + name: "main", 205 + detail: "", 206 + workingDirectory: rootURL, 207 + repositoryRootURL: rootURL 208 + ) 209 + let repository = Repository( 210 + id: rootURL.path(percentEncoded: false), 211 + rootURL: rootURL, 212 + name: "repo", 213 + worktrees: IdentifiedArray(uniqueElements: [main]) 214 + ) 215 + var state = RepositoriesFeature.State(repositories: [repository]) 216 + state.repositoryRoots = [rootURL] 217 + state.repositoryOrderIDs = [repository.id] 218 + state.openedWorktreeIDs = [main.id] 219 + 220 + let books = state.orderedShelfBooks(customTitles: [repository.id: "My Custom Repo"]) 221 + 222 + #expect(books.count == 1) 223 + #expect(books[0].projectName == "My Custom Repo") 224 + // Worktree's own displayName stays as the worktree branch — only 225 + // the repo-level project label is overridden. 226 + #expect(books[0].displayName == "main") 227 + } 228 + 229 + @Test func customTitleOverridesBothNamesForPlainFolderBook() { 230 + let rootURL = URL(fileURLWithPath: "/tmp/folder") 231 + let repository = Repository( 232 + id: rootURL.path(percentEncoded: false), 233 + rootURL: rootURL, 234 + name: "folder", 235 + kind: .plain, 236 + worktrees: [] 237 + ) 238 + var state = RepositoriesFeature.State(repositories: [repository]) 239 + state.repositoryRoots = [rootURL] 240 + state.repositoryOrderIDs = [repository.id] 241 + state.openedWorktreeIDs = [repository.id] 242 + 243 + let books = state.orderedShelfBooks(customTitles: [repository.id: "Plain Folder Alias"]) 244 + 245 + #expect(books.count == 1) 246 + #expect(books[0].kind == .plainFolder) 247 + #expect(books[0].projectName == "Plain Folder Alias") 248 + #expect(books[0].displayName == "Plain Folder Alias") 249 + } 250 + 251 + @Test func missingCustomTitleFallsBackToRepositoryName() { 252 + let rootURL = URL(fileURLWithPath: "/tmp/folder") 253 + let repository = Repository( 254 + id: rootURL.path(percentEncoded: false), 255 + rootURL: rootURL, 256 + name: "folder", 257 + kind: .plain, 258 + worktrees: [] 259 + ) 260 + var state = RepositoriesFeature.State(repositories: [repository]) 261 + state.repositoryRoots = [rootURL] 262 + state.repositoryOrderIDs = [repository.id] 263 + state.openedWorktreeIDs = [repository.id] 264 + 265 + let books = state.orderedShelfBooks(customTitles: [:]) 266 + 267 + #expect(books.count == 1) 268 + #expect(books[0].projectName == "folder") 269 + #expect(books[0].displayName == "folder") 270 + } 199 271 }
+41
supacodeTests/ToolbarNotificationGroupingTests.swift
··· 117 117 #expect(groups[0].unseenWorktreeCount == 0) 118 118 } 119 119 120 + @Test func customTitleOverridesGroupName() { 121 + let repoPath = "/tmp/repo" 122 + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) 123 + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) 124 + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) 125 + var state = RepositoriesFeature.State(repositories: [repo]) 126 + state.repositoryRoots = [repo.rootURL] 127 + 128 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 129 + manager.state(for: feature).notifications = [ 130 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") 131 + ] 132 + 133 + let groups = state.toolbarNotificationGroups( 134 + terminalManager: manager, 135 + customTitles: [repo.id: "Aliased Repo"] 136 + ) 137 + 138 + #expect(groups.count == 1) 139 + #expect(groups[0].name == "Aliased Repo") 140 + } 141 + 142 + @Test func missingCustomTitleFallsBackToRepositoryName() { 143 + let repoPath = "/tmp/repo" 144 + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) 145 + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) 146 + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) 147 + var state = RepositoriesFeature.State(repositories: [repo]) 148 + state.repositoryRoots = [repo.rootURL] 149 + 150 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 151 + manager.state(for: feature).notifications = [ 152 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") 153 + ] 154 + 155 + let groups = state.toolbarNotificationGroups(terminalManager: manager, customTitles: [:]) 156 + 157 + #expect(groups.count == 1) 158 + #expect(groups[0].name == "Repo") 159 + } 160 + 120 161 private func makeWorktree( 121 162 id: String, 122 163 name: String,