diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index ca7b1a12..a648e5e5 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -143,7 +143,7 @@ struct AppFeature { ) -> Bool { switch delegate { case .selectWorktree, .jumpToLatestUnread, .viewArchivedWorktrees, - .newWorktree, .toggleCanvas: + .newWorktree, .toggleCanvas, .renameBranch: return true default: return false @@ -1103,6 +1103,10 @@ struct AppFeature { case .commandPalette(.delegate(.stopRunScript)): return .send(.stopRunScript) + case .commandPalette(.delegate(.renameBranch)): + guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } + return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) + case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): if isCurrentlyPinned { return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index d71aa3bd..c4bc35c1 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -75,6 +75,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case deleteWorktree(Worktree.ID, Repository.ID) + case renameBranch case runCustomCommand(index: Int, commandID: String, systemImage: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -129,6 +130,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.runScript case .stopRunScript: return AppShortcuts.CommandID.stopScript + case .renameBranch: + return AppShortcuts.CommandID.renameBranch case .ghosttyCommand, .markPullRequestReady, .mergePullRequest, diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 06d600f4..54f2ae43 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -63,6 +63,7 @@ struct CommandPaletteFeature { case runScript case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) + case renameBranch case runCustomCommand(Int) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -408,6 +409,15 @@ private func worktreeActionCommandItems( keywords: pinKeywords ) ) + items.append( + .appShortcut( + id: CommandPaletteItemID.globalRenameBranch, + title: "Rename Branch", + category: .worktree, + kind: .renameBranch, + keywords: ["rename", "branch", "name"] + ) + ) if let repositoryID = repositories.repositoryID(containing: worktreeID) { items.append( CommandPaletteItem( @@ -796,6 +806,7 @@ private enum CommandPaletteItemID { static let globalRunScript = "global.run-script" static let globalStopRunScript = "global.stop-run-script" static let globalTogglePinWorktree = "global.toggle-pin-worktree" + static let globalRenameBranch = "global.rename-branch" static let globalDeleteWorktree = "global.delete-worktree" static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { @@ -823,6 +834,7 @@ private enum CommandPaletteItemID { globalRunScript, globalStopRunScript, globalTogglePinWorktree, + globalRenameBranch, globalDeleteWorktree, ] } @@ -950,7 +962,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .copyPath, .revealInSidebar, .runScript, - .stopRunScript: + .stopRunScript, + .renameBranch: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -983,6 +996,8 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .runScript case .stopRunScript: return .stopRunScript + case .renameBranch: + return .renameBranch default: return nil } @@ -1063,6 +1078,7 @@ private func pullRequestDelegateAction( .runScript, .stopRunScript, .togglePinWorktree, + .renameBranch, .deleteWorktree, .runCustomCommand: return nil diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 5abc7f21..91055631 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -541,7 +541,7 @@ private struct CommandPaletteRowView: View { .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .runCustomCommand: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .runCustomCommand: return nil case .removeWorktree: return "Remove" @@ -622,6 +622,8 @@ private struct CommandPaletteRowView: View { return "stop.circle" case .togglePinWorktree(_, let isCurrentlyPinned): return isCurrentlyPinned ? "pin.slash" : "pin" + case .renameBranch: + return "pencil" case .deleteWorktree: return "trash" case .runCustomCommand(_, _, let systemImage): @@ -645,7 +647,8 @@ private struct CommandPaletteRowView: View { .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .deleteWorktree, .runCustomCommand: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, + .deleteWorktree, .runCustomCommand: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false @@ -787,6 +790,8 @@ private struct CommandPaletteRowView: View { base = "Stop Script" case .togglePinWorktree(_, let isCurrentlyPinned): base = isCurrentlyPinned ? "Unpin Worktree" : "Pin Worktree" + case .renameBranch: + base = "Rename Branch" case .deleteWorktree: base = "Delete \(row.title)" case .runCustomCommand: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 90d298bd..ab3290d5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -50,6 +50,11 @@ struct PendingSidebarReveal: Equatable, Sendable { let worktreeID: Worktree.ID } +struct PendingRenameBranchRequest: Equatable, Sendable { + let id: Int + let worktreeID: Worktree.ID +} + @Reducer struct RepositoriesFeature { enum CancelID { @@ -238,6 +243,8 @@ struct RepositoriesFeature { var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? + var nextPendingRenameBranchRequestID = 0 + var pendingRenameBranchRequest: PendingRenameBranchRequest? var isSidebarDragActive = false var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] var activeAgents = ActiveAgentsFeature.State() @@ -313,6 +320,8 @@ struct RepositoriesFeature { case worktreeHistoryForward case revealSelectedWorktreeInSidebar case consumePendingSidebarReveal(Int) + case requestRenameBranchPrompt(Worktree.ID) + case consumePendingRenameBranchRequest(Int) case requestRenameBranch(Worktree.ID, String) case presentAlert(title: String, message: String) case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) @@ -945,6 +954,20 @@ struct RepositoriesFeature { state.pendingSidebarReveal = nil return .none + case .requestRenameBranchPrompt(let worktreeID): + guard state.worktree(for: worktreeID) != nil else { return .none } + state.nextPendingRenameBranchRequestID += 1 + state.pendingRenameBranchRequest = .init( + id: state.nextPendingRenameBranchRequestID, + worktreeID: worktreeID + ) + return .none + + case .consumePendingRenameBranchRequest(let requestID): + guard state.pendingRenameBranchRequest?.id == requestID else { return .none } + state.pendingRenameBranchRequest = nil + return .none + case .requestRenameBranch(let worktreeID, let branchName): guard let worktree = state.worktree(for: worktreeID) else { return .none } let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 5386db3d..0d36143f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -3,6 +3,8 @@ import SwiftUI struct WorktreeDetailTitleView: View { let title: DetailToolbarTitle let onSubmit: ((String) -> Void)? + let externalRenamePrompt: PendingRenameBranchRequest? + let onConsumeExternalRenamePrompt: (Int) -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var isPresented = false @@ -13,8 +15,7 @@ struct WorktreeDetailTitleView: View { Group { if title.supportsRename { Button { - draftName = title.text - isPresented = true + openRenamePopover() } label: { labelContent } @@ -48,6 +49,16 @@ struct WorktreeDetailTitleView: View { } ) } + .task(id: externalRenamePrompt?.id) { + guard let prompt = externalRenamePrompt, title.supportsRename else { return } + openRenamePopover() + onConsumeExternalRenamePrompt(prompt.id) + } + } + + private func openRenamePopover() { + draftName = title.text + isPresented = true } private var labelContent: some View { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 2b48669c..661ffa14 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -89,31 +89,12 @@ struct WorktreeDetailView: View { ) ) { - WorktreeToolbarContent( + worktreeToolbarContent( toolbarState: toolbarState, - onRenameBranch: { newBranch in - guard let selectedWorktree else { return } - store.send(.repositories(.requestRenameBranch(selectedWorktree.id, newBranch))) - }, - onOpenWorktree: { action in - store.send(.openWorktree(action)) - }, - onOpenActionSelectionChanged: { action in - store.send(.openActionSelectionChanged(action)) - }, - onCopyPath: { - guard let selectedTerminalWorktree else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(selectedTerminalWorktree.workingDirectory.path, forType: .string) - }, - onSelectNotification: selectToolbarNotification, - onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, - onRunScript: { store.send(.runScript) }, - onStopRunScript: { store.send(.stopRunScript) }, - onRunCustomCommand: { index in - store.send(.runCustomCommand(index)) - }, - onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + repositories: repositories, + selectedWorktree: selectedWorktree, + selectedTerminalWorktree: selectedTerminalWorktree, + notificationGroups: notificationGroups ) } } @@ -126,6 +107,49 @@ struct WorktreeDetailView: View { return applyFocusedActions(content: content, actions: actions) } + @ToolbarContentBuilder + private func worktreeToolbarContent( + toolbarState: WorktreeToolbarState, + repositories: RepositoriesFeature.State, + selectedWorktree: Worktree?, + selectedTerminalWorktree: Worktree?, + notificationGroups: [ToolbarNotificationRepositoryGroup] + ) -> some ToolbarContent { + WorktreeToolbarContent( + toolbarState: toolbarState, + onRenameBranch: { newBranch in + guard let selectedWorktree else { return } + store.send(.repositories(.requestRenameBranch(selectedWorktree.id, newBranch))) + }, + externalRenamePrompt: repositories.pendingRenameBranchRequest + .flatMap { request in + request.worktreeID == selectedWorktree?.id ? request : nil + }, + onConsumeExternalRenamePrompt: { requestID in + store.send(.repositories(.consumePendingRenameBranchRequest(requestID))) + }, + onOpenWorktree: { action in + store.send(.openWorktree(action)) + }, + onOpenActionSelectionChanged: { action in + store.send(.openActionSelectionChanged(action)) + }, + onCopyPath: { + guard let selectedTerminalWorktree else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selectedTerminalWorktree.workingDirectory.path, forType: .string) + }, + onSelectNotification: selectToolbarNotification, + onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, + onRunScript: { store.send(.runScript) }, + onStopRunScript: { store.send(.stopRunScript) }, + onRunCustomCommand: { index in + store.send(.runCustomCommand(index)) + }, + onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + ) + } + @ToolbarContentBuilder private func canvasToolbarContent( notificationGroups: [ToolbarNotificationRepositoryGroup], @@ -477,6 +501,8 @@ struct WorktreeDetailView: View { fileprivate struct WorktreeToolbarContent: ToolbarContent { let toolbarState: WorktreeToolbarState let onRenameBranch: (String) -> Void + let externalRenamePrompt: PendingRenameBranchRequest? + let onConsumeExternalRenamePrompt: (Int) -> Void let onOpenWorktree: (OpenWorktreeAction) -> Void let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void let onCopyPath: () -> Void @@ -492,7 +518,9 @@ struct WorktreeDetailView: View { ToolbarItem { WorktreeDetailTitleView( title: toolbarState.title, - onSubmit: toolbarState.title.supportsRename ? onRenameBranch : nil + onSubmit: toolbarState.title.supportsRename ? onRenameBranch : nil, + externalRenamePrompt: externalRenamePrompt, + onConsumeExternalRenamePrompt: onConsumeExternalRenamePrompt ) } @@ -975,6 +1003,8 @@ private struct WorktreeToolbarPreview: View { WorktreeDetailView.WorktreeToolbarContent( toolbarState: toolbarState, onRenameBranch: { _ in }, + externalRenamePrompt: nil, + onConsumeExternalRenamePrompt: { _ in }, onOpenWorktree: { _ in }, onOpenActionSelectionChanged: { _ in }, onCopyPath: {}, diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 31df65d0..ecd456ac 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -537,6 +537,45 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.repositories.worktreeOrdering.unpinWorktree) } + @Test(.dependencies) func renameBranchDelegateDispatchesRequestPrompt() async { + let worktree = makeWorktree( + id: "/tmp/repo-rename/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-rename" + ) + let repository = makeRepository(id: "/tmp/repo-rename", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.renameBranch))) + await store.receive(\.repositories.requestRenameBranchPrompt) { + $0.repositories.nextPendingRenameBranchRequestID = 1 + $0.repositories.pendingRenameBranchRequest = PendingRenameBranchRequest( + id: 1, + worktreeID: worktree.id + ) + } + } + + @Test(.dependencies) func renameBranchDelegateNoopsWithoutSelectedWorktree() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.renameBranch))) + await store.finish() + } + @Test(.dependencies) func runCustomCommandDelegateDispatchesAppAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 90f5e595..20f70fae 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -128,6 +128,20 @@ struct CommandPaletteFeatureTests { #expect(pinItem?.kind == .togglePinWorktree(worktree.id, isCurrentlyPinned: true)) } + @Test func commandPaletteItems_includesRenameBranchForNonMainWorktree() { + let rootPath = "/tmp/repo-rename" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let renameItem = items.first { $0.id == "global.rename-branch" } + #expect(renameItem?.title == "Rename Branch") + #expect(renameItem?.kind == .renameBranch) + #expect(renameItem?.appShortcutCommandID == AppShortcuts.CommandID.renameBranch) + } + @Test func commandPaletteItems_omitsPinAndDeleteForMainWorktree() { let rootPath = "/tmp/repo-main" // Main worktree: workingDirectory == repositoryRootURL → Worktree.isMain == true @@ -145,6 +159,7 @@ struct CommandPaletteFeatureTests { let ids = Set(items.map(\.id)) #expect(!ids.contains("global.toggle-pin-worktree")) #expect(!ids.contains("global.delete-worktree")) + #expect(!ids.contains("global.rename-branch")) } @Test func commandPaletteItems_includesDeleteWorktreeForNonMain() { @@ -1647,7 +1662,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .runScript, .stopRunScript, .togglePinWorktree, - .deleteWorktree, .runCustomCommand: + .renameBranch, .deleteWorktree, .runCustomCommand: return .worktree case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation @@ -1674,7 +1689,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost,