diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 232b39c1..4dd2bb92 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -335,6 +335,7 @@ struct AppFeature { var effects: [Effect] = [ .send(.settings(.setSelection(.general))), .send(.commandPalette(.pruneRecency(recencyIDs))), + .send(.repositories(.refreshAllCustomTitles)), .run { _ in await terminalClient.send(.prune(ids)) }, @@ -353,6 +354,7 @@ struct AppFeature { } var effects: [Effect] = [ .send(.commandPalette(.pruneRecency(recencyIDs))), + .send(.repositories(.refreshAllCustomTitles)), .run { _ in await terminalClient.send(.prune(ids)) }, @@ -787,15 +789,21 @@ struct AppFeature { } case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL)))): + // Always refresh the repo's custom title cache — display sites + // (sidebar, shelf, canvas, toolbar, settings list) read it from + // `RepositoriesFeature.State.repositoryCustomTitles` rather + // than subscribing to the per-repo settings file directly. + let refreshCustomTitle = Effect.send(.repositories(.refreshCustomTitle(rootURL))) guard let selectedWorktree = state.repositories.selectedTerminalWorktree, selectedWorktree.repositoryRootURL == rootURL else { - return .none + return refreshCustomTitle } let worktreeID = selectedWorktree.id @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings return .concatenate( + refreshCustomTitle, .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) ) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 2ac88b39..f9895643 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -7,6 +7,11 @@ struct CanvasView: View { @Environment(\.resolvedKeybindings) private var resolvedKeybindings let terminalManager: WorktreeTerminalManager + /// Per-repo display titles resolved by the parent reducer. Used to + /// override the folder-derived `Repository.name` on each card title + /// bar without subscribing to per-repo settings files on the + /// per-frame canvas hot path. + var repositoryCustomTitles: [Repository.ID: String] = [:] var onExitToTab: () -> Void = {} @State private var layoutStore = CanvasLayoutStore() @Shared(.repositoryAppearances) private var repositoryAppearances @@ -85,8 +90,9 @@ struct CanvasView: View { let cardTotalHeight = resized.size.height + titleBarHeight let repositoryAppearance = appearance(for: state.repositoryRootURL) + let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) CanvasCardView( - repositoryName: Repository.name(for: state.repositoryRootURL), + repositoryName: resolvedRepositoryName, worktreeName: tab.title, repositoryIcon: repositoryAppearance.icon, repositoryColor: repositoryAppearance.color?.color, @@ -780,12 +786,27 @@ struct CanvasView: View { /// dict. Returns `.empty` when no entry exists, which keeps cards /// visually identical to before the appearance feature shipped. private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { - let id = - PathPolicy.normalizePath( - repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true - ) ?? repositoryRootURL.path(percentEncoded: false) + let id = repositoryID(for: repositoryRootURL) return repositoryAppearances[id] ?? .empty } + + /// Resolves the user-defined display title for the repo at this root + /// URL, falling back to `Repository.name(for:)` (folder name) when no + /// custom title was set. Reads from the static dictionary populated + /// by the parent reducer — no per-call `@Shared` subscription on the + /// canvas hot path. + private func repositoryDisplayName(for repositoryRootURL: URL) -> String { + let id = repositoryID(for: repositoryRootURL) + return repositoryCustomTitles[id] ?? Repository.name(for: repositoryRootURL) + } + + /// Mirrors the same path normalization the `Repository.ID` is built + /// from, so dict lookups match what the reducer stores. + private func repositoryID(for repositoryRootURL: URL) -> Repository.ID { + PathPolicy.normalizePath( + repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true + ) ?? repositoryRootURL.path(percentEncoded: false) + } } private struct ActiveResize { diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index e0258caf..7d503de0 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -26,8 +26,12 @@ struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { } extension RepositoriesFeature.State { + /// `customTitles` is an optional per-repo display-name dictionary; + /// when an entry exists the group's `name` uses it instead of + /// `repository.name`. Defaults to empty for legacy callers/tests. func toolbarNotificationGroups( - terminalManager: WorktreeTerminalManager + terminalManager: WorktreeTerminalManager, + customTitles: [Repository.ID: String] = [:] ) -> [ToolbarNotificationRepositoryGroup] { let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) var groups: [ToolbarNotificationRepositoryGroup] = [] @@ -54,7 +58,7 @@ extension RepositoriesFeature.State { groups.append( ToolbarNotificationRepositoryGroup( id: repository.id, - name: repository.name, + name: customTitles[repository.id] ?? repository.name, worktrees: worktreeGroups ) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 68296eeb..a3d85ed7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -187,6 +187,13 @@ struct RepositoriesFeature { var repositoryRoots: [URL] = [] var repositoryOrderIDs: [Repository.ID] = [] var loadFailuresByID: [Repository.ID: String] = [:] + /// User-defined display titles indexed by `Repository.ID`. Resolved + /// once on repo discovery (and refreshed when settings change) so + /// hot-path display sites — sidebar, shelf spine, canvas card, + /// toolbar notifications, settings list — read a plain dictionary + /// instead of subscribing to `@Shared(.repositorySettings(...))` + /// per row per frame. Absent entries fall back to `repository.name`. + var repositoryCustomTitles: [Repository.ID: String] = [:] var selection: SidebarSelection? var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] @@ -276,6 +283,10 @@ struct RepositoriesFeature { case refreshWorktrees case reloadRepositories(animated: Bool) case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) + case refreshAllCustomTitles + case refreshCustomTitle(URL) + case customTitlesLoaded([Repository.ID: String]) + case customTitleUpdated(Repository.ID, String?) case codeHostsDetected([Repository.ID: CodeHost]) case selectArchivedWorktrees case selectCanvas @@ -632,6 +643,52 @@ struct RepositoriesFeature { } return .merge(allEffects) + case .refreshAllCustomTitles: + // Fan out across the current repository list, reading each + // per-repo settings file via `@Shared`. Runs in a reducer + // effect (not in a view body), so even when the first cache + // miss triggers a `settingsFile` write the resulting view + // re-render can't loop back into this action. + let repositoriesForTitleRefresh = Array(state.repositories) + return .run { send in + var dict: [Repository.ID: String] = [:] + for repository in repositoriesForTitleRefresh { + @Shared(.repositorySettings(repository.rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + dict[repository.id] = trimmed + } + } + await send(.customTitlesLoaded(dict)) + } + + case .refreshCustomTitle(let rootURL): + guard let repository = state.repositories.first(where: { $0.rootURL == rootURL }) else { + return .none + } + let repositoryID = repository.id + return .run { send in + @Shared(.repositorySettings(rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = (trimmed?.isEmpty ?? true) ? nil : trimmed + await send(.customTitleUpdated(repositoryID, normalized)) + } + + case .customTitlesLoaded(let dict): + guard state.repositoryCustomTitles != dict else { return .none } + state.repositoryCustomTitles = dict + return .none + + case .customTitleUpdated(let id, let title): + if let title { + guard state.repositoryCustomTitles[id] != title else { return .none } + state.repositoryCustomTitles[id] = title + } else { + guard state.repositoryCustomTitles[id] != nil else { return .none } + state.repositoryCustomTitles.removeValue(forKey: id) + } + return .none + case .codeHostsDetected(let codeHostByRepositoryID): let knownIDs = Set(state.repositories.ids) var updated = state.codeHostByRepositoryID.filter { knownIDs.contains($0.key) } diff --git a/supacode/Features/Repositories/Views/RepoDisplayName.swift b/supacode/Features/Repositories/Views/RepoDisplayName.swift index 6e1f66be..485e70ff 100644 --- a/supacode/Features/Repositories/Views/RepoDisplayName.swift +++ b/supacode/Features/Repositories/Views/RepoDisplayName.swift @@ -1,48 +1,21 @@ -import Sharing import SwiftUI -/// Renders the repository display label, preferring the user's custom -/// title from `RepositorySettings` over the folder-derived fallback. +/// Renders the repository display label, preferring a user-defined +/// `customTitle` over the folder-derived `fallbackName`. /// -/// Subscription is isolated to this leaf view so callers don't pull in -/// `@Shared(.repositorySettings(...))` themselves and parent views -/// don't churn on settings changes. Mirrors the per-leaf-subscription -/// pattern used by `RepoHeaderTabCountBadge`. -/// -/// The view emits a plain `Text` — callers apply their own font / -/// foreground style modifiers so this view stays appearance-agnostic. +/// Stateless on purpose: the source of truth for `customTitle` lives +/// in `RepositoriesFeature.State.repositoryCustomTitles` (refreshed by +/// the reducer when settings change), so display sites read a plain +/// string and avoid per-row `@Shared(.repositorySettings(...))` +/// subscriptions on the hot path. Callers apply their own font / +/// foreground style modifiers — this view stays appearance-agnostic. struct RepoDisplayName: View { let fallbackName: String - let repositoryRootURL: URL? + var customTitle: String? var tooltip: String? var body: some View { - if let repositoryRootURL { - RepoDisplayNameResolved( - rootURL: repositoryRootURL, - fallbackName: fallbackName, - tooltip: tooltip - ) - } else { - Text(fallbackName) - .help(tooltip ?? "") - } - } -} - -private struct RepoDisplayNameResolved: View { - let fallbackName: String - let tooltip: String? - @Shared private var settings: RepositorySettings - - init(rootURL: URL, fallbackName: String, tooltip: String?) { - self.fallbackName = fallbackName - self.tooltip = tooltip - _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) - } - - var body: some View { - Text(settings.customTitle ?? fallbackName) + Text(customTitle ?? fallbackName) .help(tooltip ?? "") } } diff --git a/supacode/Features/Repositories/Views/RepoHeaderRow.swift b/supacode/Features/Repositories/Views/RepoHeaderRow.swift index 7117b2fe..c6fc9212 100644 --- a/supacode/Features/Repositories/Views/RepoHeaderRow.swift +++ b/supacode/Features/Repositories/Views/RepoHeaderRow.swift @@ -3,6 +3,9 @@ import SwiftUI struct RepoHeaderRow: View { private static let debugHeaderLayers = false let name: String + /// User-defined display title resolved by the parent reducer. When + /// non-nil, takes precedence over `name` for display. + var customTitle: String? let isRemoving: Bool /// User-pinned icon, when set. Renders before the repo name. /// `nil` keeps the historical text-only layout intact. @@ -27,7 +30,7 @@ struct RepoHeaderRow: View { } RepoDisplayName( fallbackName: name, - repositoryRootURL: repositoryRootURL, + customTitle: customTitle, tooltip: nameTooltip ) .foregroundStyle(.secondary) diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index 3710dbe9..8ddfcb55 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -2,6 +2,9 @@ import SwiftUI struct RepositoryDetailView: View { let repository: Repository + /// Resolved by the parent reducer. When non-nil, takes precedence + /// over `repository.name` for display. + var customTitle: String? var body: some View { VStack(spacing: 12) { @@ -10,7 +13,7 @@ struct RepositoryDetailView: View { .accessibilityHidden(true) RepoDisplayName( fallbackName: repository.name, - repositoryRootURL: repository.rootURL + customTitle: customTitle ) .font(.title3.weight(.semibold)) Text(repository.rootURL.path(percentEncoded: false)) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 7bec12b1..51ff229b 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -46,6 +46,7 @@ struct RepositorySectionView: View { HStack { RepoHeaderRow( name: repository.name, + customTitle: store.repositoryCustomTitles[repository.id], isRemoving: isRemovingRepository, icon: appearance.icon, iconTint: appearance.color?.color, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f293c1d5..21d15aef 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -48,7 +48,10 @@ struct WorktreeDetailView: View { let runScriptEnabled = hasActiveTerminalTarget let runScriptIsRunning = selectedTerminalWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true let customCommands = state.selectedCustomCommands - let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) + let notificationGroups = repositories.toolbarNotificationGroups( + terminalManager: terminalManager, + customTitles: repositories.repositoryCustomTitles + ) let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in count + repository.unseenWorktreeCount } @@ -221,6 +224,7 @@ struct WorktreeDetailView: View { if repositories.isShowingCanvas { CanvasView( terminalManager: terminalManager, + repositoryCustomTitles: repositories.repositoryCustomTitles, onExitToTab: { store.send(.repositories(.toggleCanvas)) }) @@ -260,7 +264,10 @@ struct WorktreeDetailView: View { } } } else if let selectedRepository = repositories.selectedRepository { - RepositoryDetailView(repository: selectedRepository) + RepositoryDetailView( + repository: selectedRepository, + customTitle: repositories.repositoryCustomTitles[selectedRepository.id] + ) } else { EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) } @@ -911,7 +918,7 @@ private struct WorktreeToolbarPreview: View { key: "u", modifiers: UserCustomShortcutModifiers() ) - ) + ), ], isUpdateAvailable: true, availableUpdateVersion: "2026.5.1" diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 9aeb6e8f..8b891f67 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Sharing import SwiftUI extension View { @@ -25,6 +24,7 @@ struct SettingsView: View { var body: some View { let updatesStore = store.scope(state: \.updates, action: \.updates) let repositories = store.repositories.repositories + let customTitles = store.repositories.repositoryCustomTitles let selection = settingsStore.selection ?? .general NavigationSplitView(columnVisibility: .constant(.all)) { @@ -49,7 +49,7 @@ struct SettingsView: View { ForEach(repositories) { repository in RepoDisplayName( fallbackName: repository.name, - repositoryRootURL: repository.rootURL + customTitle: customTitles[repository.id] ) .tag(SettingsSection.repository(repository.id)) } @@ -110,10 +110,10 @@ struct SettingsView: View { IfLetStore( settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) ) { repositorySettingsStore in - RepositorySettingsDetailContainer( - store: repositorySettingsStore, - repository: repository - ) + RepositorySettingsView(store: repositorySettingsStore) + .id(repository.id) + .navigationTitle(customTitles[repository.id] ?? repository.name) + .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) } } } else { @@ -136,29 +136,3 @@ struct SettingsView: View { .ignoresSafeArea(.container, edges: .top) } } - -/// Wraps `RepositorySettingsView` with a `@Shared` subscription on the -/// repo's settings file so the navigation title can reflect the user's -/// custom title (when set) instead of the folder-derived `Repository.name`. -/// Lives here rather than inside `RepositorySettingsView` because the -/// `.navigationTitle` modifier needs a `String`, not a view, and reading -/// `@Shared` requires a struct property — wrapping at this layer keeps -/// `RepositorySettingsView`'s interface untouched. -private struct RepositorySettingsDetailContainer: View { - let store: StoreOf - let repository: Repository - @Shared private var settings: RepositorySettings - - init(store: StoreOf, repository: Repository) { - self.store = store - self.repository = repository - _settings = Shared(wrappedValue: .default, .repositorySettings(repository.rootURL)) - } - - var body: some View { - RepositorySettingsView(store: store) - .id(repository.id) - .navigationTitle(settings.customTitle ?? repository.name) - .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) - } -} diff --git a/supacode/Features/Shelf/Models/ShelfBook.swift b/supacode/Features/Shelf/Models/ShelfBook.swift index d95733dd..672fdab3 100644 --- a/supacode/Features/Shelf/Models/ShelfBook.swift +++ b/supacode/Features/Shelf/Models/ShelfBook.swift @@ -38,7 +38,16 @@ extension RepositoriesFeature.State { /// Clicking a previously-unopened worktree in the left navigation /// while in Shelf mode adds its ID here, which causes its spine to /// materialize (with the standard spine-flow animation). - func orderedShelfBooks() -> [ShelfBook] { + /// Builds the ordered list of shelf books from current state. + /// + /// `customTitles` is an optional dictionary providing user-defined + /// display names per repository. Defaults to empty for callers that + /// don't care (e.g. legacy tests). The resolved `projectName` (and + /// `displayName` for plain folders) prefers the custom title when + /// present and falls back to `repository.name` otherwise. + func orderedShelfBooks( + customTitles: [Repository.ID: String] = [:] + ) -> [ShelfBook] { // `ShelfView.body` re-runs on every TCA state change, so this method // is on the per-frame hot path. The previous implementation built a // `Dictionary(uniqueKeysWithValues:)` per call and routed worktree @@ -51,14 +60,15 @@ extension RepositoriesFeature.State { var books: [ShelfBook] = [] for repositoryID in orderedRepositoryIDs() { guard let repository = repositories[id: repositoryID] else { continue } + let projectName = customTitles[repositoryID] ?? repository.name if repository.kind == .plain { guard openedWorktreeIDs.contains(repository.id) else { continue } books.append( ShelfBook( id: repository.id, repositoryID: repository.id, - displayName: repository.name, - projectName: repository.name, + displayName: projectName, + projectName: projectName, branchName: nil, kind: .plainFolder )) @@ -71,7 +81,7 @@ extension RepositoriesFeature.State { id: worktree.id, repositoryID: repositoryID, displayName: worktree.name, - projectName: repository.name, + projectName: projectName, branchName: worktree.name, kind: .worktree )) @@ -90,7 +100,7 @@ extension RepositoriesFeature.State { id: pending.id, repositoryID: repositoryID, displayName: pending.progress.titleText, - projectName: repository.name, + projectName: projectName, branchName: pending.progress.titleText, kind: .worktree )) diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 5215d4b2..a8f4b50b 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -31,7 +31,7 @@ struct ShelfView: View { // sanity-checking how often the root re-renders during animation. let _ = shelfLogger.event("ShelfView.body") let state = store.state - let books = state.orderedShelfBooks() + let books = state.orderedShelfBooks(customTitles: state.repositoryCustomTitles) let openBookID = state.openShelfBookID let openIndex = openBookID.flatMap { id in books.firstIndex(where: { $0.id == id }) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2e93ded8..418d10d2 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -67,6 +67,47 @@ struct RepositoriesFeatureTests { } } + @Test func customTitlesLoadedReplacesEntireDictionary() async { + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) { + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] + } + + // Re-sending the same dict is a no-op (state mutation guard avoids + // gratuitous TCA-driven view refreshes). + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) + + await store.send(.customTitlesLoaded(["repo-c": "Gamma"])) { + $0.repositoryCustomTitles = ["repo-c": "Gamma"] + } + } + + @Test func customTitleUpdatedSetsAndRemovesSingleEntry() async { + var initialState = RepositoriesFeature.State() + initialState.repositoryCustomTitles = ["repo-a": "Alpha"] + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.customTitleUpdated("repo-b", "Beta")) { + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] + } + + // Same value → no state change + await store.send(.customTitleUpdated("repo-b", "Beta")) + + // nil removes the entry + await store.send(.customTitleUpdated("repo-a", nil)) { + $0.repositoryCustomTitles = ["repo-b": "Beta"] + } + + // Removing a non-existent entry is a no-op + await store.send(.customTitleUpdated("repo-a", nil)) + } + @Test func updateWorktreeLineChangesReturnsFalseWhenCountsMatchExistingEntry() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -622,7 +663,7 @@ struct RepositoriesFeatureTests { [ PersistedRepositoryEntry(path: repoRoot, kind: .git), PersistedRepositoryEntry(path: plainRoot, kind: .plain), - ] + ], ] #expect(savedEntries.value == expectedSavedEntries) } @@ -1953,7 +1994,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ) + ), ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1991,7 +2032,7 @@ struct RepositoriesFeatureTests { stage: .checkingRepositoryMode, worktreeName: "swift-otter" ) - ) + ), ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2186,7 +2227,7 @@ struct RepositoriesFeatureTests { addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "MERGED") - ) + ), ] let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { @@ -2880,7 +2921,7 @@ struct RepositoriesFeatureTests { id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) - ) + ), ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ @@ -2967,7 +3008,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ) + ), ] initialState.selection = .worktree(pendingID) initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] diff --git a/supacodeTests/ShelfBookOrderingTests.swift b/supacodeTests/ShelfBookOrderingTests.swift index 30ea311d..a1e919a0 100644 --- a/supacodeTests/ShelfBookOrderingTests.swift +++ b/supacodeTests/ShelfBookOrderingTests.swift @@ -196,4 +196,76 @@ struct ShelfBookOrderingTests { #expect(state.openShelfBookID == repository.id) } + + @Test func customTitleOverridesProjectNameForWorktreeBooks() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let main = Worktree( + id: "/tmp/repo", + name: "main", + detail: "", + workingDirectory: rootURL, + repositoryRootURL: rootURL + ) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main]) + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [main.id] + + let books = state.orderedShelfBooks(customTitles: [repository.id: "My Custom Repo"]) + + #expect(books.count == 1) + #expect(books[0].projectName == "My Custom Repo") + // Worktree's own displayName stays as the worktree branch — only + // the repo-level project label is overridden. + #expect(books[0].displayName == "main") + } + + @Test func customTitleOverridesBothNamesForPlainFolderBook() { + let rootURL = URL(fileURLWithPath: "/tmp/folder") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "folder", + kind: .plain, + worktrees: [] + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [repository.id] + + let books = state.orderedShelfBooks(customTitles: [repository.id: "Plain Folder Alias"]) + + #expect(books.count == 1) + #expect(books[0].kind == .plainFolder) + #expect(books[0].projectName == "Plain Folder Alias") + #expect(books[0].displayName == "Plain Folder Alias") + } + + @Test func missingCustomTitleFallsBackToRepositoryName() { + let rootURL = URL(fileURLWithPath: "/tmp/folder") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "folder", + kind: .plain, + worktrees: [] + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [repository.id] + + let books = state.orderedShelfBooks(customTitles: [:]) + + #expect(books.count == 1) + #expect(books[0].projectName == "folder") + #expect(books[0].displayName == "folder") + } } diff --git a/supacodeTests/ToolbarNotificationGroupingTests.swift b/supacodeTests/ToolbarNotificationGroupingTests.swift index 79919726..7231615a 100644 --- a/supacodeTests/ToolbarNotificationGroupingTests.swift +++ b/supacodeTests/ToolbarNotificationGroupingTests.swift @@ -117,6 +117,47 @@ struct ToolbarNotificationGroupingTests { #expect(groups[0].unseenWorktreeCount == 0) } + @Test func customTitleOverridesGroupName() { + let repoPath = "/tmp/repo" + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) + var state = RepositoriesFeature.State(repositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + manager.state(for: feature).notifications = [ + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") + ] + + let groups = state.toolbarNotificationGroups( + terminalManager: manager, + customTitles: [repo.id: "Aliased Repo"] + ) + + #expect(groups.count == 1) + #expect(groups[0].name == "Aliased Repo") + } + + @Test func missingCustomTitleFallsBackToRepositoryName() { + let repoPath = "/tmp/repo" + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) + var state = RepositoriesFeature.State(repositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + manager.state(for: feature).notifications = [ + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") + ] + + let groups = state.toolbarNotificationGroups(terminalManager: manager, customTitles: [:]) + + #expect(groups.count == 1) + #expect(groups[0].name == "Repo") + } + private func makeWorktree( id: String, name: String,