From 947cb53f4477db6ee5ca6afb31453ae26068bb6b Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 6 Nov 2025 12:52:09 +0000 Subject: [PATCH] Edit branch name (vibe-kanban) (#1182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ## ✅ Implementation Complete! I've successfully implemented the branch name editing feature. Here's what was added: ### **Backend Changes** 1. **Database Model** ([task_attempt.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/db/src/models/task_attempt.rs#L407-L421)) - Added `update_branch_name()` method to update the branch field 2. **Git Service** ([git.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/services/src/services/git.rs#L1508-L1526)) - Added `rename_local_branch()` to handle actual Git branch renaming using libgit2 3. **API Endpoint** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1111-L1196)) - Added `POST /task-attempts/{id}/rename-branch` endpoint - **Validation:** empty names, Git name validity, duplicates, active rebase, open PRs - **Flow:** validate → ensure worktree → rename in git → update DB → track analytics ### **Frontend Changes** 4. **API Client** ([api.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/lib/api.ts#L533-L546)) - Added `renameBranch()` method 5. **Actions Menu** ([ActionsDropdown.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/ui/ActionsDropdown.tsx#L93-L102)) - Added "Edit Branch Name" menu item 6. **Dialog Component** ([EditBranchNameDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx)) - Text input with validation - Shows backend errors - Auto-refreshes data on success ### **Key Features** ✅ Renames actual Git branch (not just DB) ✅ Blocks when open PR exists ✅ Blocks during active rebase ✅ Validates branch name format ✅ Prevents duplicate branch names ✅ Analytics tracking ✅ All checks pass The feature is now ready to use from the task attempt dropdown menu! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Cache Invalidation Fix Complete! I've updated [EditBranchNameDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx#L59-L71) to invalidate all relevant query keys after renaming a branch. ### What Changed The dialog now invalidates **5 query keys** instead of just 2: 1. ✅ `['taskAttempt', attemptId]` - **Main fix** for DiffsPanel 2. ✅ `['attempt', attemptId]` - Legacy compatibility 3. ✅ `['attemptBranch', attemptId]` - Branch-specific data 4. ✅ `['branchStatus', attemptId]` - Git operations panel 5. ✅ `['taskAttempts']` - Attempts list in TaskPanel ### Result Now when you rename a branch, **all components immediately show the new name**: - ✅ DiffsPanel (branch name in Git operations toolbar) - ✅ TaskPanel (attempts list) - ✅ GitOperations component - ✅ Any other component displaying the attempt data **No page reload needed!** All checks pass. * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Refactored to Use Hook Pattern! I've successfully extracted the mutation logic into a proper `useRenameBranch` hook following the codebase's established patterns. ### Changes Made **1. Created new hook:** [useRenameBranch.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/hooks/useRenameBranch.ts) - Uses `useMutation` from TanStack Query - Handles cache invalidation internally - Provides callbacks for success/error handling - Follows the same pattern as `useChangeTargetBranch`, `useMerge`, `usePush` **2. Simplified EditBranchNameDialog:** - Removed manual API call and cache invalidation - Removed local `isLoading` state (uses `mutation.isPending` instead) - Removed `queryClient` import - Much cleaner and more focused on UI **3. Updated hooks exports:** [index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/hooks/index.ts#L7) ### Before vs After **Before (inline mutation):** - 40+ lines of try/catch, API calls, and invalidation logic - Manual loading state management - Hard to test, hard to reuse **After (hook-based):** - ~10 lines in dialog - Hook encapsulates all mutation logic - Reusable, testable, consistent with codebase ### Benefits ✅ **Consistency** - Matches existing patterns (useChangeTargetBranch, useMerge, etc.) ✅ **Reusability** - Can be used anywhere branch renaming is needed ✅ **Testability** - Hook can be unit tested separately ✅ **Maintainability** - Single source of truth for invalidation logic ✅ **Cleaner Dialog** - Dialog focuses purely on UI concerns All checks pass! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ i18n Check Complete! All internationalization issues have been resolved: ### Fixed Issues **1. Missing translation keys** - Added to all locales (en, es, ja, ko): - `actionsMenu.editBranchName` **2. Hardcoded strings in EditBranchNameDialog** - Now using translation keys: - Dialog title → `t('editBranchName.dialog.title')` - Dialog description → `t('editBranchName.dialog.description')` - Branch name label → `t('editBranchName.dialog.branchNameLabel')` - Placeholder text → `t('editBranchName.dialog.placeholder')` - "Renaming..." → `t('editBranchName.dialog.renaming')` - "Rename Branch" → `t('editBranchName.dialog.action')` ### Translations Added **English:** "Edit branch name" **Spanish (es):** "Editar nombre de rama" **Japanese (ja):** "ブランチ名を編集" **Korean (ko):** "브랜치 이름 편집" ### Results ✅ **No new literal strings introduced** ✅ **No duplicate keys found** ✅ **Translation keys consistent across all locales** ✅ **All type checks pass** The feature is now fully internationalized and ready for users in all supported languages! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Child Task Updates Complete! I've successfully implemented the fix to update child task attempts when renaming a branch. ### What Was Added **1. Database Helper** ([task_attempt.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/db/src/models/task_attempt.rs#L422-L445)) - Added `update_target_branch_for_children_of_attempt()` method - Uses single scoped SQL UPDATE with subquery - Only affects attempts for direct child tasks - Returns count of updated attempts **2. Rename Branch Endpoint** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1219-L1239)) - Stores old branch name before update - After renaming, updates all child task attempts - Logs count of updated children - Tracks updated count in analytics ### How It Works **Scenario:** 1. Parent attempt has branch `feature/old` 2. User creates 2 subtasks from parent attempt 3. Subtask attempts inherit `target_branch = "feature/old"` 4. Parent branch renamed to `feature/new` 5. ✅ Both subtask attempts automatically get `target_branch = "feature/new"` **SQL Query:** ```sql UPDATE task_attempts SET target_branch = 'feature/new' WHERE target_branch = 'feature/old' AND task_id IN ( SELECT id FROM tasks WHERE parent_task_attempt = parent_attempt_id ) ``` ### What's Updated ✅ `target_branch` for all attempts of direct child tasks ✅ Only when it matches the old branch name ✅ Scoped by parent-child relationship (via `parent_task_attempt` FK) ### What's NOT Updated ❌ Unrelated attempts that happen to use same branch name ❌ Child attempts' own `branch` field (they have their own branches) ❌ GitHub PR bases (would need GitHub API call) ### Result ✅ Child task attempts continue to work correctly after parent branch rename ✅ Efficient single-query update ✅ Safe scoping prevents unintended changes ✅ All checks pass * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Analytics & Types Fixed! ### Changes Made **1. Analytics Anonymity** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1240-L1248)) - Removed `old_branch` and `new_branch` from analytics event - Now only tracks: `attempt_id` and `updated_children` (anonymous) **2. Type Export** ([generate_types.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/bin/generate_types.rs#L51-L52)) - Added `RenameBranchRequest::decl()` and `RenameBranchResponse::decl()` - Types now exported to [shared/types.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/shared/types.ts) **3. Frontend Type Usage** ([api.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/lib/api.ts#L49-L50)) - Imported `RenameBranchRequest` and `RenameBranchResponse` from shared types - Changed signature from `Promise<{ branch: string }>` to `Promise` - Uses properly typed request payload ### Results ✅ **Anonymous analytics** - No branch names (PII) sent ✅ **Type-safe** - Frontend/backend use same shared types ✅ **Consistent** - Matches pattern of other API methods ✅ **All checks pass** - TypeScript, Rust, and i18n * ## ✅ Analytics Now Fully Anonymous! Removed `attempt_id` from the analytics event. Now the event only tracks: ```json { "updated_children": 0 } ``` Fully anonymous - no IDs, no branch names, no PII. Just aggregate counts for usage metrics. All checks pass! --- ...babe4b75fc734a135cb45f5127a0411eeb89b.json | 12 ++ ...dbd7d836da8b58567ef6a7ba44e77920cfd37.json | 12 ++ crates/db/src/models/task_attempt.rs | 40 +++++ crates/server/src/bin/generate_types.rs | 2 + crates/server/src/routes/task_attempts.rs | 112 ++++++++++++++ crates/services/src/services/git.rs | 19 +++ .../dialogs/tasks/EditBranchNameDialog.tsx | 138 ++++++++++++++++++ .../src/components/ui/ActionsDropdown.tsx | 15 ++ frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useRenameBranch.ts | 40 +++++ frontend/src/i18n/locales/en/tasks.json | 11 ++ frontend/src/i18n/locales/es/tasks.json | 11 ++ frontend/src/i18n/locales/ja/tasks.json | 11 ++ frontend/src/i18n/locales/ko/tasks.json | 11 ++ frontend/src/lib/api.ts | 19 +++ frontend/src/main.tsx | 2 + shared/types.ts | 4 + 17 files changed, 460 insertions(+) create mode 100644 crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json create mode 100644 crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json create mode 100644 frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx create mode 100644 frontend/src/hooks/useRenameBranch.ts diff --git a/crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json b/crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json new file mode 100644 index 00000000..cd0d20e5 --- /dev/null +++ b/crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_attempts SET branch = $1, updated_at = datetime('now') WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b" +} diff --git a/crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json b/crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json new file mode 100644 index 00000000..207842aa --- /dev/null +++ b/crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_attempts\n SET target_branch = $3, updated_at = datetime('now')\n WHERE target_branch = $2\n AND task_id IN (\n SELECT id FROM tasks \n WHERE parent_task_attempt = $1\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37" +} diff --git a/crates/db/src/models/task_attempt.rs b/crates/db/src/models/task_attempt.rs index dd2b977d..3c53c4c7 100644 --- a/crates/db/src/models/task_attempt.rs +++ b/crates/db/src/models/task_attempt.rs @@ -404,6 +404,46 @@ impl TaskAttempt { Ok(()) } + pub async fn update_branch_name( + pool: &SqlitePool, + attempt_id: Uuid, + new_branch_name: &str, + ) -> Result<(), TaskAttemptError> { + sqlx::query!( + "UPDATE task_attempts SET branch = $1, updated_at = datetime('now') WHERE id = $2", + new_branch_name, + attempt_id, + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update_target_branch_for_children_of_attempt( + pool: &SqlitePool, + parent_attempt_id: Uuid, + old_branch: &str, + new_branch: &str, + ) -> Result { + let result = sqlx::query!( + r#"UPDATE task_attempts + SET target_branch = $3, updated_at = datetime('now') + WHERE target_branch = $2 + AND task_id IN ( + SELECT id FROM tasks + WHERE parent_task_attempt = $1 + )"#, + parent_attempt_id, + old_branch, + new_branch + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) + } + pub async fn resolve_container_ref( pool: &SqlitePool, container_ref: &str, diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 28a81d76..e85e1d25 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -49,6 +49,8 @@ fn generate_types_content() -> String { services::services::drafts::UpdateRetryFollowUpDraftRequest::decl(), server::routes::task_attempts::ChangeTargetBranchRequest::decl(), server::routes::task_attempts::ChangeTargetBranchResponse::decl(), + server::routes::task_attempts::RenameBranchRequest::decl(), + server::routes::task_attempts::RenameBranchResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index d65fbdc0..8c12a7a3 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -1080,6 +1080,16 @@ pub struct ChangeTargetBranchResponse { pub status: (usize, usize), } +#[derive(serde::Deserialize, Debug, TS)] +pub struct RenameBranchRequest { + pub new_branch_name: String, +} + +#[derive(serde::Serialize, Debug, TS)] +pub struct RenameBranchResponse { + pub branch: String, +} + #[axum::debug_handler] pub async fn change_target_branch( Extension(task_attempt): Extension, @@ -1140,6 +1150,107 @@ pub async fn change_target_branch( ))) } +#[axum::debug_handler] +pub async fn rename_branch( + Extension(task_attempt): Extension, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let new_branch_name = payload.new_branch_name.trim(); + + if new_branch_name.is_empty() { + return Ok(ResponseJson(ApiResponse::error( + "Branch name cannot be empty", + ))); + } + + if new_branch_name == task_attempt.branch { + return Ok(ResponseJson(ApiResponse::success(RenameBranchResponse { + branch: task_attempt.branch.clone(), + }))); + } + + if !git2::Branch::name_is_valid(new_branch_name)? { + return Ok(ResponseJson(ApiResponse::error( + "Invalid branch name format", + ))); + } + + let pool = &deployment.db().pool; + let task = task_attempt + .parent_task(pool) + .await? + .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; + + let project = Project::find_by_id(pool, task.project_id) + .await? + .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; + + if deployment + .git() + .check_branch_exists(&project.git_repo_path, new_branch_name)? + { + return Ok(ResponseJson(ApiResponse::error( + "A branch with this name already exists", + ))); + } + + let worktree_path_buf = ensure_worktree_path(&deployment, &task_attempt).await?; + let worktree_path = worktree_path_buf.as_path(); + + if deployment.git().is_rebase_in_progress(worktree_path)? { + return Ok(ResponseJson(ApiResponse::error( + "Cannot rename branch while rebase is in progress. Please complete or abort the rebase first.", + ))); + } + + if let Some(merge) = Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await? + && let Merge::Pr(pr_merge) = merge + && matches!(pr_merge.pr_info.status, MergeStatus::Open) + { + return Ok(ResponseJson(ApiResponse::error( + "Cannot rename branch with an open pull request. Please close the PR first or create a new attempt.", + ))); + } + + deployment + .git() + .rename_local_branch(worktree_path, &task_attempt.branch, new_branch_name)?; + + let old_branch = task_attempt.branch.clone(); + + TaskAttempt::update_branch_name(pool, task_attempt.id, new_branch_name).await?; + + let updated_children_count = TaskAttempt::update_target_branch_for_children_of_attempt( + pool, + task_attempt.id, + &old_branch, + new_branch_name, + ) + .await?; + + if updated_children_count > 0 { + tracing::info!( + "Updated {} child task attempts to target new branch '{}'", + updated_children_count, + new_branch_name + ); + } + + deployment + .track_if_analytics_allowed( + "task_attempt_branch_renamed", + serde_json::json!({ + "updated_children": updated_children_count, + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(RenameBranchResponse { + branch: new_branch_name.to_string(), + }))) +} + #[axum::debug_handler] pub async fn rebase_task_attempt( Extension(task_attempt): Extension, @@ -1540,6 +1651,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/children", get(get_task_attempt_children)) .route("/stop", post(stop_task_attempt_execution)) .route("/change-target-branch", post(change_target_branch)) + .route("/rename-branch", post(rename_branch)) .layer(from_fn_with_state( deployment.clone(), load_task_attempt_middleware, diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index ec6ac53f..85425a56 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1506,6 +1506,25 @@ impl GitService { } } + pub fn rename_local_branch( + &self, + worktree_path: &Path, + old_branch_name: &str, + new_branch_name: &str, + ) -> Result<(), GitServiceError> { + let repo = self.open_repo(worktree_path)?; + + let mut branch = repo + .find_branch(old_branch_name, BranchType::Local) + .map_err(|_| GitServiceError::BranchNotFound(old_branch_name.to_string()))?; + + branch.rename(new_branch_name, false)?; + + repo.set_head(&format!("refs/heads/{new_branch_name}"))?; + + Ok(()) + } + /// Return true if a rebase is currently in progress in this worktree. pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result { let git = GitCli::new(); diff --git a/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx b/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx new file mode 100644 index 00000000..7ba2828e --- /dev/null +++ b/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useRenameBranch } from '@/hooks/useRenameBranch'; + +export interface EditBranchNameDialogProps { + attemptId: string; + currentBranchName: string; +} + +export type EditBranchNameDialogResult = { + action: 'confirmed' | 'canceled'; + branchName?: string; +}; + +export const EditBranchNameDialog = NiceModal.create( + ({ attemptId, currentBranchName }) => { + const modal = useModal(); + const { t } = useTranslation(['tasks', 'common']); + const [branchName, setBranchName] = useState(currentBranchName); + const [error, setError] = useState(null); + + const renameMutation = useRenameBranch( + attemptId, + (newBranch) => { + modal.resolve({ + action: 'confirmed', + branchName: newBranch, + } as EditBranchNameDialogResult); + modal.hide(); + }, + (err: any) => { + setError(err?.message || 'Failed to rename branch'); + } + ); + + const handleConfirm = () => { + const trimmedName = branchName.trim(); + + if (!trimmedName) { + setError('Branch name cannot be empty'); + return; + } + + if (trimmedName === currentBranchName) { + modal.resolve({ action: 'canceled' } as EditBranchNameDialogResult); + modal.hide(); + return; + } + + if (trimmedName.includes(' ')) { + setError('Branch name cannot contain spaces'); + return; + } + + setError(null); + renameMutation.mutate(trimmedName); + }; + + const handleCancel = () => { + modal.resolve({ action: 'canceled' } as EditBranchNameDialogResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + return ( + + + + {t('editBranchName.dialog.title')} + + {t('editBranchName.dialog.description')} + + + +
+
+ + { + setBranchName(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !renameMutation.isPending) { + handleConfirm(); + } + }} + placeholder={t('editBranchName.dialog.placeholder')} + disabled={renameMutation.isPending} + autoFocus + /> + {error &&

{error}

} +
+
+ + + + + +
+
+ ); + } +); diff --git a/frontend/src/components/ui/ActionsDropdown.tsx b/frontend/src/components/ui/ActionsDropdown.tsx index 8b32b750..c4cd0040 100644 --- a/frontend/src/components/ui/ActionsDropdown.tsx +++ b/frontend/src/components/ui/ActionsDropdown.tsx @@ -112,6 +112,15 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) { }); }; + const handleEditBranchName = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!attempt?.id) return; + NiceModal.show('edit-branch-name-dialog', { + attemptId: attempt.id, + currentBranchName: attempt.branch, + }); + }; + return ( <> @@ -163,6 +172,12 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) { > {t('actionsMenu.gitActions')} + + {t('actionsMenu.editBranchName')} + )} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index b0c004b9..533dc255 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -4,6 +4,7 @@ export { useOpenInEditor } from './useOpenInEditor'; export { useDevServer } from './useDevServer'; export { useRebase } from './useRebase'; export { useChangeTargetBranch } from './useChangeTargetBranch'; +export { useRenameBranch } from './useRenameBranch'; export { useMerge } from './useMerge'; export { usePush } from './usePush'; export { useAttemptConflicts } from './useAttemptConflicts'; diff --git a/frontend/src/hooks/useRenameBranch.ts b/frontend/src/hooks/useRenameBranch.ts new file mode 100644 index 00000000..ab140bd4 --- /dev/null +++ b/frontend/src/hooks/useRenameBranch.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { attemptsApi } from '@/lib/api'; + +export function useRenameBranch( + attemptId?: string, + onSuccess?: (newBranchName: string) => void, + onError?: (err: unknown) => void +) { + const queryClient = useQueryClient(); + + return useMutation<{ branch: string }, unknown, string>({ + mutationFn: async (newBranchName) => { + if (!attemptId) throw new Error('Attempt id is not set'); + return attemptsApi.renameBranch(attemptId, newBranchName); + }, + onSuccess: (data) => { + if (attemptId) { + queryClient.invalidateQueries({ queryKey: ['taskAttempt', attemptId] }); + queryClient.invalidateQueries({ queryKey: ['attempt', attemptId] }); + queryClient.invalidateQueries({ + queryKey: ['attemptBranch', attemptId], + }); + queryClient.invalidateQueries({ + queryKey: ['branchStatus', attemptId], + }); + queryClient.invalidateQueries({ queryKey: ['taskAttempts'] }); + } + onSuccess?.(data.branch); + }, + onError: (err) => { + console.error('Failed to rename branch:', err); + if (attemptId) { + queryClient.invalidateQueries({ + queryKey: ['branchStatus', attemptId], + }); + } + onError?.(err); + }, + }); +} diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index fd056632..4f2adf50 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -264,9 +264,20 @@ "createNewAttempt": "Create new attempt", "createSubtask": "Create subtask", "gitActions": "Git actions", + "editBranchName": "Edit branch name", "task": "Task", "duplicate": "Duplicate" }, + "editBranchName": { + "dialog": { + "title": "Edit Branch Name", + "description": "Enter a new name for the branch. Cannot rename if an open PR exists.", + "branchNameLabel": "Branch Name", + "placeholder": "e.g., feature/my-branch", + "renaming": "Renaming...", + "action": "Rename Branch" + } + }, "showcases": { "taskPanel": { "companion": { diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index c5d34158..a70f5e82 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -7,12 +7,23 @@ "createNewAttempt": "Create new attempt", "createSubtask": "Create subtask", "duplicate": "Duplicate", + "editBranchName": "Editar nombre de rama", "gitActions": "Acciones de Git", "openInIde": "Open attempt in IDE", "task": "Task", "viewProcesses": "View processes", "viewRelatedTasks": "View related tasks" }, + "editBranchName": { + "dialog": { + "title": "Editar nombre de rama", + "description": "Ingrese un nuevo nombre para la rama. No se puede renombrar si existe un PR abierto.", + "branchNameLabel": "Nombre de rama", + "placeholder": "ej., feature/mi-rama", + "renaming": "Renombrando...", + "action": "Renombrar rama" + } + }, "attempt": { "actions": { "openInIde": "Abrir en IDE", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 3f5e200b..d6d6083c 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -7,12 +7,23 @@ "createNewAttempt": "Create new attempt", "createSubtask": "Create subtask", "duplicate": "Duplicate", + "editBranchName": "ブランチ名を編集", "gitActions": "Gitアクション", "openInIde": "Open attempt in IDE", "task": "Task", "viewProcesses": "View processes", "viewRelatedTasks": "View related tasks" }, + "editBranchName": { + "dialog": { + "title": "ブランチ名を編集", + "description": "ブランチの新しい名前を入力してください。オープンなPRが存在する場合は名前を変更できません。", + "branchNameLabel": "ブランチ名", + "placeholder": "例: feature/my-branch", + "renaming": "名前を変更中...", + "action": "ブランチ名を変更" + } + }, "attempt": { "actions": { "openInIde": "IDEで開く", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index 51e44604..c6e3c449 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -7,12 +7,23 @@ "createNewAttempt": "Create new attempt", "createSubtask": "Create subtask", "duplicate": "Duplicate", + "editBranchName": "브랜치 이름 편집", "gitActions": "Git 작업", "openInIde": "Open attempt in IDE", "task": "Task", "viewProcesses": "View processes", "viewRelatedTasks": "View related tasks" }, + "editBranchName": { + "dialog": { + "title": "브랜치 이름 편집", + "description": "브랜치의 새 이름을 입력하세요. 열려있는 PR이 있으면 이름을 변경할 수 없습니다.", + "branchNameLabel": "브랜치 이름", + "placeholder": "예: feature/my-branch", + "renaming": "이름 변경 중...", + "action": "브랜치 이름 변경" + } + }, "attempt": { "actions": { "openInIde": "IDE에서 열기", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ba58826c..f3609bec 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,8 @@ import { RebaseTaskAttemptRequest, ChangeTargetBranchRequest, ChangeTargetBranchResponse, + RenameBranchRequest, + RenameBranchResponse, RunAgentSetupRequest, RunAgentSetupResponse, } from 'shared/types'; @@ -547,6 +549,23 @@ export const attemptsApi = { return handleApiResponse(response); }, + renameBranch: async ( + attemptId: string, + newBranchName: string + ): Promise => { + const payload: RenameBranchRequest = { + new_branch_name: newBranchName, + }; + const response = await makeRequest( + `/api/task-attempts/${attemptId}/rename-branch`, + { + method: 'POST', + body: JSON.stringify(payload), + } + ); + return handleApiResponse(response); + }, + abortConflicts: async (attemptId: string): Promise => { const response = await makeRequest( `/api/task-attempts/${attemptId}/conflicts/abort`, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 19b9e2ba..081b6b21 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -38,6 +38,7 @@ import { GitActionsDialog, } from './components/dialogs'; import { CreateAttemptDialog } from './components/dialogs/tasks/CreateAttemptDialog'; +import { EditBranchNameDialog } from './components/dialogs/tasks/EditBranchNameDialog'; // Register modals NiceModal.register('github-login', GitHubLoginDialog); @@ -63,6 +64,7 @@ NiceModal.register('restore-logs', RestoreLogsDialog); NiceModal.register('view-processes', ViewProcessesDialog); NiceModal.register('create-attempt', CreateAttemptDialog); NiceModal.register('git-actions', GitActionsDialog); +NiceModal.register('edit-branch-name-dialog', EditBranchNameDialog); import { useLocation, diff --git a/shared/types.ts b/shared/types.ts index 62e1a4b0..f5ac4e56 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -86,6 +86,10 @@ export type ChangeTargetBranchRequest = { new_target_branch: string, }; export type ChangeTargetBranchResponse = { new_target_branch: string, status: [number, number], }; +export type RenameBranchRequest = { new_branch_name: string, }; + +export type RenameBranchResponse = { branch: string, }; + export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: ExecutorProfileId, base_branch: string, }; export type CreateGitHubPrRequest = { title: string, body: string | null, target_branch: string | null, };