native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #247 from onevcat/feat/custom-repo-title

feat(settings): add custom repo title for sidebar

authored by

Wei Wang and committed by
GitHub
41344e95 8ccbea7e

+445 -55
+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)
+21
supacode/Features/Repositories/Views/RepoDisplayName.swift
··· 1 + import SwiftUI 2 + 3 + /// Renders the repository display label, preferring a user-defined 4 + /// `customTitle` over the folder-derived `fallbackName`. 5 + /// 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. 12 + struct RepoDisplayName: View { 13 + let fallbackName: String 14 + var customTitle: String? 15 + var tooltip: String? 16 + 17 + var body: some View { 18 + Text(customTitle ?? fallbackName) 19 + .help(tooltip ?? "") 20 + } 21 + }
+9 -3
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. ··· 25 28 size: 14 26 29 ) 27 30 } 28 - Text(name) 29 - .foregroundStyle(.secondary) 30 - .help(nameTooltip ?? "") 31 + RepoDisplayName( 32 + fallbackName: name, 33 + customTitle: customTitle, 34 + tooltip: nameTooltip 35 + ) 36 + .foregroundStyle(.secondary) 31 37 if isRemoving { 32 38 Text("Removing...") 33 39 .font(.caption)
+8 -2
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) { 8 11 Image(systemName: repository.kind == .git ? "folder.badge.gearshape" : "folder") 9 12 .font(.largeTitle) 10 13 .accessibilityHidden(true) 11 - Text(repository.name) 12 - .font(.title3.weight(.semibold)) 14 + RepoDisplayName( 15 + fallbackName: repository.name, 16 + customTitle: customTitle 17 + ) 18 + .font(.title3.weight(.semibold)) 13 19 Text(repository.rootURL.path(percentEncoded: false)) 14 20 .font(.subheadline.monospaced()) 15 21 .foregroundStyle(.secondary)
+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"
+5
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 275 275 normalizedSettings.worktreeBaseDirectoryPath, 276 276 repositoryRootURL: rootURL 277 277 ) 278 + let trimmedCustomTitle = 279 + normalizedSettings.customTitle? 280 + .trimmingCharacters(in: .whitespacesAndNewlines) 281 + normalizedSettings.customTitle = 282 + (trimmedCustomTitle?.isEmpty ?? true) ? nil : trimmedCustomTitle 278 283 @Shared(.repositorySettings(rootURL)) var repositorySettings 279 284 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 280 285 $repositorySettings.withLock { $0 = normalizedSettings }
+15 -11
supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift
··· 35 35 private let swatchRingLineWidth: CGFloat = 1.5 36 36 37 37 var body: some View { 38 - VStack(alignment: .leading, spacing: 12) { 38 + VStack(alignment: .leading, spacing: 10) { 39 39 iconRow 40 40 colorRow 41 41 if let message = store.appearanceImportError { ··· 201 201 202 202 @ViewBuilder 203 203 private var colorRow: some View { 204 - VStack(alignment: .leading, spacing: 8) { 205 - Text("Color") 206 - .font(.headline) 204 + VStack(alignment: .leading, spacing: 6) { 205 + HStack(alignment: .center, spacing: 12) { 206 + Text("Color") 207 + .font(.headline) 208 + .frame(width: previewSize, alignment: .center) 209 + HStack(spacing: 8) { 210 + ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in 211 + colorSwatch(for: choice) 212 + } 213 + noColorSwatch 214 + Spacer(minLength: 0) 215 + } 216 + } 207 217 Text( 208 218 "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." 209 219 ) 210 220 .font(.caption) 211 221 .foregroundStyle(.secondary) 212 222 .fixedSize(horizontal: false, vertical: true) 213 - HStack(spacing: 8) { 214 - ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in 215 - colorSwatch(for: choice) 216 - } 217 - noColorSwatch 218 - Spacer(minLength: 0) 219 - } 223 + .padding(.leading, previewSize + 12) 220 224 } 221 225 } 222 226
+9 -2
supacode/Features/Settings/Models/RepositorySettings.swift
··· 15 15 var copyIgnoredOnWorktreeCreate: Bool? 16 16 var copyUntrackedOnWorktreeCreate: Bool? 17 17 var pullRequestMergeStrategy: PullRequestMergeStrategy? 18 + var customTitle: String? 18 19 private var schemaVersion: Int 19 20 20 21 private enum CodingKeys: String, CodingKey { ··· 28 29 case copyIgnoredOnWorktreeCreate 29 30 case copyUntrackedOnWorktreeCreate 30 31 case pullRequestMergeStrategy 32 + case customTitle 31 33 } 32 34 33 35 static let `default` = RepositorySettings( ··· 39 41 worktreeBaseDirectoryPath: nil, 40 42 copyIgnoredOnWorktreeCreate: nil, 41 43 copyUntrackedOnWorktreeCreate: nil, 42 - pullRequestMergeStrategy: nil 44 + pullRequestMergeStrategy: nil, 45 + customTitle: nil 43 46 ) 44 47 45 48 init( ··· 51 54 worktreeBaseDirectoryPath: String? = nil, 52 55 copyIgnoredOnWorktreeCreate: Bool? = nil, 53 56 copyUntrackedOnWorktreeCreate: Bool? = nil, 54 - pullRequestMergeStrategy: PullRequestMergeStrategy? = nil 57 + pullRequestMergeStrategy: PullRequestMergeStrategy? = nil, 58 + customTitle: String? = nil 55 59 ) { 56 60 self.setupScript = setupScript 57 61 self.archiveScript = archiveScript ··· 62 66 self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate 63 67 self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 64 68 self.pullRequestMergeStrategy = pullRequestMergeStrategy 69 + self.customTitle = customTitle 65 70 schemaVersion = Self.currentSchemaVersion 66 71 } 67 72 ··· 86 91 try container.decodeIfPresent(String.self, forKey: .worktreeBaseRef) 87 92 worktreeBaseDirectoryPath = 88 93 try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) 94 + customTitle = 95 + try container.decodeIfPresent(String.self, forKey: .customTitle) 89 96 if decodedSchemaVersion >= Self.currentSchemaVersion { 90 97 copyIgnoredOnWorktreeCreate = 91 98 try container.decodeIfPresent(
+12 -9
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 63 63 get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, 64 64 set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, 65 65 ) 66 + let customTitle = Binding( 67 + get: { settings.customTitle.wrappedValue ?? "" }, 68 + set: { settings.customTitle.wrappedValue = $0 }, 69 + ) 66 70 let exampleWorktreePath = store.exampleWorktreePath 71 + let folderName = Repository.name(for: store.rootURL) 67 72 68 73 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) 74 + Section("Display") { 75 + VStack(alignment: .leading, spacing: 12) { 76 + TextField("Name", text: customTitle, prompt: Text(folderName)) 77 + .textFieldStyle(.roundedBorder) 78 + Divider() 79 + RepositoryAppearancePickerView(store: store) 78 80 } 81 + .frame(maxWidth: .infinity, alignment: .leading) 79 82 } 80 83 81 84 if store.showsWorktreeSettings {
+7 -3
supacode/Features/Settings/Views/SettingsView.swift
··· 24 24 var body: some View { 25 25 let updatesStore = store.scope(state: \.updates, action: \.updates) 26 26 let repositories = store.repositories.repositories 27 + let customTitles = store.repositories.repositoryCustomTitles 27 28 let selection = settingsStore.selection ?? .general 28 29 29 30 NavigationSplitView(columnVisibility: .constant(.all)) { ··· 46 47 47 48 Section("Repositories") { 48 49 ForEach(repositories) { repository in 49 - Text(repository.name) 50 - .tag(SettingsSection.repository(repository.id)) 50 + RepoDisplayName( 51 + fallbackName: repository.name, 52 + customTitle: customTitles[repository.id] 53 + ) 54 + .tag(SettingsSection.repository(repository.id)) 51 55 } 52 56 } 53 57 } ··· 108 112 ) { repositorySettingsStore in 109 113 RepositorySettingsView(store: repositorySettingsStore) 110 114 .id(repository.id) 111 - .navigationTitle(repository.name) 115 + .navigationTitle(customTitles[repository.id] ?? repository.name) 112 116 .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) 113 117 } 114 118 }
+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]
+74 -2
supacodeTests/RepositorySettingsFeatureTests.swift
··· 135 135 key: "b", 136 136 modifiers: UserCustomShortcutModifiers(command: true) 137 137 ) 138 - ) 138 + ), 139 139 ] 140 140 ) 141 141 ··· 149 149 #expect(decoded.customCommands.first?.shortcut == conflicted.customCommands.first?.shortcut) 150 150 } 151 151 152 + @Test(.dependencies) func customTitleBindingPersistsToRepositoryFile() async throws { 153 + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 154 + let settingsStorage = SettingsTestStorage() 155 + let localStorage = RepositoryLocalSettingsTestStorage() 156 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 157 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 158 + 159 + // Pre-seed a per-repo settings file so save() writes through to it 160 + // instead of falling back to the global settings file. 161 + let seedData = try #require(try? JSONEncoder().encode(RepositorySettings.default)) 162 + try #require(try? localStorage.save(seedData, at: repositorySettingsURL)) 163 + 164 + let store = TestStore( 165 + initialState: RepositorySettingsFeature.State( 166 + rootURL: rootURL, 167 + repositoryKind: .plain, 168 + settings: .default, 169 + userSettings: .default 170 + ) 171 + ) { 172 + RepositorySettingsFeature() 173 + } withDependencies: { 174 + $0.settingsFileStorage = settingsStorage.storage 175 + $0.settingsFileURL = settingsFileURL 176 + $0.repositoryLocalSettingsStorage = localStorage.storage 177 + } 178 + 179 + await store.send(.binding(.set(\.settings.customTitle, "My Custom Repo"))) { 180 + $0.settings.customTitle = "My Custom Repo" 181 + } 182 + await store.receive(\.delegate.settingsChanged) 183 + 184 + let savedData = try #require(localStorage.data(at: repositorySettingsURL)) 185 + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: savedData) 186 + #expect(decoded.customTitle == "My Custom Repo") 187 + } 188 + 189 + @Test(.dependencies) func customTitleWhitespaceOnlyPersistsAsNil() async throws { 190 + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 191 + let settingsStorage = SettingsTestStorage() 192 + let localStorage = RepositoryLocalSettingsTestStorage() 193 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 194 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 195 + 196 + let seedData = try #require(try? JSONEncoder().encode(RepositorySettings.default)) 197 + try #require(try? localStorage.save(seedData, at: repositorySettingsURL)) 198 + 199 + let store = TestStore( 200 + initialState: RepositorySettingsFeature.State( 201 + rootURL: rootURL, 202 + repositoryKind: .plain, 203 + settings: .default, 204 + userSettings: .default 205 + ) 206 + ) { 207 + RepositorySettingsFeature() 208 + } withDependencies: { 209 + $0.settingsFileStorage = settingsStorage.storage 210 + $0.settingsFileURL = settingsFileURL 211 + $0.repositoryLocalSettingsStorage = localStorage.storage 212 + } 213 + 214 + await store.send(.binding(.set(\.settings.customTitle, " "))) { 215 + $0.settings.customTitle = " " 216 + } 217 + await store.receive(\.delegate.settingsChanged) 218 + 219 + let savedData = try #require(localStorage.data(at: repositorySettingsURL)) 220 + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: savedData) 221 + #expect(decoded.customTitle == nil) 222 + } 223 + 152 224 @Test(.dependencies) func taskLoadsLatestUserSettingsAfterAsyncGitProbe() async throws { 153 225 let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 154 226 let settingsStorage = SettingsTestStorage() ··· 167 239 command: "echo updated", 168 240 execution: .shellScript, 169 241 shortcut: nil 170 - ) 242 + ), 171 243 ] 172 244 ) 173 245
+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,