feat: add rename branch functionality with UI integration

This commit is contained in:
Bohdan Triapitsyn
2026-01-08 01:13:51 +02:00
parent d6f74087c4
commit 8902662f74
13 changed files with 284 additions and 3 deletions
@@ -1696,6 +1696,22 @@ pub async fn create_branch(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn rename_branch(
directory: String,
old_name: String,
new_name: String,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
run_git(&["branch", "-m", &old_name, &new_name], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn get_git_log( pub async fn get_git_log(
directory: String, directory: String,
+2 -1
View File
@@ -30,7 +30,7 @@ use axum::{
}; };
use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file}; use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file};
use commands::git::{ use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
ensure_openchamber_ignored, generate_commit_message, get_commit_files, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
@@ -863,6 +863,7 @@ fn main() {
git_fetch, git_fetch,
checkout_branch, checkout_branch,
create_branch, create_branch,
rename_branch,
get_git_log, get_git_log,
get_commit_files, get_commit_files,
get_git_identities, get_git_identities,
+9
View File
@@ -183,6 +183,15 @@ export const createDesktopGitAPI = (): GitAPI => ({
return { success: true, branch: name }; return { success: true, branch: name };
}, },
async renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> {
await safeGitInvoke<void>('rename_branch', {
directory,
oldName,
newName
});
return { success: true, branch: newName };
},
async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> { async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> {
return safeGitInvoke<GitLogResponse>('get_git_log', { return safeGitInvoke<GitLogResponse>('get_git_log', {
directory, directory,
@@ -424,6 +424,21 @@ export const GitView: React.FC = () => {
} }
}; };
const handleRenameBranch = async (oldName: string, newName: string) => {
if (!currentDirectory) return;
try {
await git.renameBranch(currentDirectory, oldName, newName);
toast.success(`Renamed branch ${oldName} to ${newName}`);
await refreshStatusAndBranches();
await refreshLog();
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to rename branch ${oldName} to ${newName}`;
toast.error(message);
}
};
const handleCheckoutBranch = async (branch: string) => { const handleCheckoutBranch = async (branch: string) => {
if (!currentDirectory) return; if (!currentDirectory) return;
const normalized = branch.replace(/^remotes\//, ''); const normalized = branch.replace(/^remotes\//, '');
@@ -652,6 +667,7 @@ export const GitView: React.FC = () => {
onPush={() => handleSyncAction('push')} onPush={() => handleSyncAction('push')}
onCheckoutBranch={handleCheckoutBranch} onCheckoutBranch={handleCheckoutBranch}
onCreateBranch={handleCreateBranch} onCreateBranch={handleCreateBranch}
onRenameBranch={handleRenameBranch}
activeIdentityProfile={activeIdentityProfile} activeIdentityProfile={activeIdentityProfile}
availableIdentities={availableIdentities} availableIdentities={availableIdentities}
onSelectIdentity={handleApplyIdentity} onSelectIdentity={handleApplyIdentity}
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { BranchSelector } from './BranchSelector'; import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions'; import { SyncActions } from './SyncActions';
import type { GitStatus, GitIdentityProfile } from '@/lib/api/types'; import type { GitStatus, GitIdentityProfile } from '@/lib/api/types';
@@ -37,6 +38,7 @@ interface GitHeaderProps {
onPush: () => void; onPush: () => void;
onCheckoutBranch: (branch: string) => void; onCheckoutBranch: (branch: string) => void;
onCreateBranch: (name: string) => Promise<void>; onCreateBranch: (name: string) => Promise<void>;
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
activeIdentityProfile: GitIdentityProfile | null; activeIdentityProfile: GitIdentityProfile | null;
availableIdentities: GitIdentityProfile[]; availableIdentities: GitIdentityProfile[];
onSelectIdentity: (profile: GitIdentityProfile) => void; onSelectIdentity: (profile: GitIdentityProfile) => void;
@@ -187,6 +189,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onPush, onPush,
onCheckoutBranch, onCheckoutBranch,
onCreateBranch, onCreateBranch,
onRenameBranch,
activeIdentityProfile, activeIdentityProfile,
availableIdentities, availableIdentities,
onSelectIdentity, onSelectIdentity,
@@ -199,7 +202,12 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
return ( return (
<header className="flex flex-wrap items-center gap-2 border-b border-border/40 px-3 py-2 bg-background"> <header className="flex flex-wrap items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
{!isWorktreeMode && ( {isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector <BranchSelector
currentBranch={status.current} currentBranch={status.current}
localBranches={localBranches} localBranches={localBranches}
@@ -0,0 +1,140 @@
import React from 'react';
import { RiGitBranchLine, RiEditLine, RiCheckLine, RiCloseLine, RiLoader4Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface WorktreeBranchDisplayProps {
currentBranch: string | null | undefined;
onRename?: (oldName: string, newName: string) => Promise<void>;
}
const sanitizeBranchNameInput = (value: string): string => {
return value
.trim()
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9._/-]/g, '-')
.replace(/-+/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\/-+/g, '/')
.replace(/-+\//g, '/')
.replace(/^[-/]+/, '')
.replace(/[-/]+$/, '');
};
export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
currentBranch,
onRename,
}) => {
const [isEditing, setIsEditing] = React.useState(false);
const [editBranchName, setEditBranchName] = React.useState(currentBranch || '');
const [isRenaming, setIsRenaming] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleStartEdit = () => {
if (!currentBranch || !onRename) return;
setEditBranchName(currentBranch);
setIsEditing(true);
// Focus input after state update
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleSaveEdit = async () => {
if (!currentBranch || !onRename || !editBranchName.trim()) return;
const sanitizedName = sanitizeBranchNameInput(editBranchName);
if (sanitizedName === currentBranch) {
setIsEditing(false);
return;
}
setIsRenaming(true);
try {
await onRename(currentBranch, sanitizedName);
setIsEditing(false);
setEditBranchName('');
} finally {
setIsRenaming(false);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditBranchName('');
};
// Handle Enter key to save, Escape to cancel
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
if (isEditing) {
return (
<div className="flex items-center gap-2 rounded-md bg-primary/12 px-2 py-1 h-8">
<form
className="flex w-full items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
handleSaveEdit();
}}
>
<RiGitBranchLine className="size-4 text-primary" />
<input
ref={inputRef}
value={editBranchName}
onChange={(e) => setEditBranchName(e.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
placeholder="Branch name"
onKeyDown={handleKeyDown}
autoFocus
/>
<button
type="submit"
disabled={isRenaming}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{isRenaming ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiCheckLine className="size-4" />
)}
</button>
<button
type="button"
onClick={handleCancelEdit}
disabled={isRenaming}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<RiCloseLine className="size-4" />
</button>
</form>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-1 h-8">
<RiGitBranchLine className="size-4 text-primary" />
<span className="max-w-[140px] truncate typography-ui-label font-normal text-foreground">
{currentBranch || 'Detached HEAD'}
</span>
</div>
{onRename && currentBranch && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleStartEdit}
title="Rename branch"
>
<RiEditLine className="size-4" />
</Button>
)}
</div>
);
};
+1
View File
@@ -281,6 +281,7 @@ export interface GitAPI {
gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>; gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>;
checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>; checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>;
createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>; createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>;
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>; getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>; getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>; getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
+10
View File
@@ -179,6 +179,16 @@ export async function createBranch(
return gitHttp.createBranch(directory, name, startPoint); return gitHttp.createBranch(directory, name, startPoint);
} }
export async function renameBranch(
directory: string,
oldName: string,
newName: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.renameBranch(directory, oldName, newName);
return gitHttp.renameBranch(directory, oldName, newName);
}
export async function getGitLog( export async function getGitLog(
directory: string, directory: string,
options: import('./api/types').GitLogOptions = {} options: import('./api/types').GitLogOptions = {}
+17
View File
@@ -404,6 +404,23 @@ export async function createBranch(
return response.json(); return response.json();
} }
export async function renameBranch(
directory: string,
oldName: string,
newName: string
): Promise<{ success: boolean; branch: string }> {
const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldName, newName }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to rename branch');
}
return response.json();
}
export async function getGitLog( export async function getGitLog(
directory: string, directory: string,
options: GitLogOptions = {} options: GitLogOptions = {}
+9
View File
@@ -174,6 +174,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({
}); });
}, },
renameBranch: async (directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> => {
return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/branches/rename', {
directory,
method: 'PUT',
oldName,
newName,
});
},
getGitLog: async (directory: string, options?: GitLogOptions): Promise<GitLogResponse> => { getGitLog: async (directory: string, options?: GitLogOptions): Promise<GitLogResponse> => {
return sendBridgeMessage<GitLogResponse>('api:git/log', { return sendBridgeMessage<GitLogResponse>('api:git/log', {
directory, directory,
+24
View File
@@ -3818,6 +3818,30 @@ async function main(options = {}) {
} }
}); });
app.put('/api/git/branches/rename', async (req, res) => {
const { renameBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { oldName, newName } = req.body;
if (!oldName) {
return res.status(400).json({ error: 'oldName is required' });
}
if (!newName) {
return res.status(400).json({ error: 'newName is required' });
}
const result = await renameBranch(directory, oldName, newName);
res.json(result);
} catch (error) {
console.error('Failed to rename branch:', error);
res.status(500).json({ error: error.message || 'Failed to rename branch' });
}
});
app.delete('/api/git/remote-branches', async (req, res) => { app.delete('/api/git/remote-branches', async (req, res) => {
const { deleteRemoteBranch } = await getGitLibraries(); const { deleteRemoteBranch } = await getGitLibraries();
try { try {
+30 -1
View File
@@ -29,6 +29,22 @@ const normalizeDirectoryPath = (value) => {
return trimmed; return trimmed;
}; };
const cleanBranchName = (branch) => {
if (!branch) {
return branch;
}
if (branch.startsWith('refs/heads/')) {
return branch.substring('refs/heads/'.length);
}
if (branch.startsWith('heads/')) {
return branch.substring('heads/'.length);
}
if (branch.startsWith('refs/')) {
return branch.substring('refs/'.length);
}
return branch;
};
export async function isGitRepository(directory) { export async function isGitRepository(directory) {
const directoryPath = normalizeDirectoryPath(directory); const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath)) { if (!directoryPath || !fs.existsSync(directoryPath)) {
@@ -805,7 +821,7 @@ export async function getWorktrees(directory) {
} else if (line.startsWith('HEAD ')) { } else if (line.startsWith('HEAD ')) {
current.head = line.substring(5); current.head = line.substring(5);
} else if (line.startsWith('branch ')) { } else if (line.startsWith('branch ')) {
current.branch = line.substring(7); current.branch = cleanBranchName(line.substring(7));
} else if (line === '') { } else if (line === '') {
if (current.worktree) { if (current.worktree) {
worktrees.push(current); worktrees.push(current);
@@ -1086,3 +1102,16 @@ export async function getCommitFiles(directory, commitHash) {
throw error; throw error;
} }
} }
export async function renameBranch(directory, oldName, newName) {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
// Use git branch -m command to rename the branch
await git.raw(['branch', '-m', oldName, newName]);
return { success: true, branch: newName };
} catch (error) {
console.error('Failed to rename branch:', error);
throw error;
}
}
+1
View File
@@ -28,6 +28,7 @@ export const createWebGitAPI = (): GitAPI => ({
gitFetch: gitApiHttp.gitFetch, gitFetch: gitApiHttp.gitFetch,
checkoutBranch: gitApiHttp.checkoutBranch, checkoutBranch: gitApiHttp.checkoutBranch,
createBranch: gitApiHttp.createBranch, createBranch: gitApiHttp.createBranch,
renameBranch: gitApiHttp.renameBranch,
getGitLog(directory: string, options?: GitLogOptions) { getGitLog(directory: string, options?: GitLogOptions) {
return gitApiHttp.getGitLog(directory, options); return gitApiHttp.getGitLog(directory, options);
}, },