diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cbb2cc2..d94a32b7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -322,21 +322,91 @@ async fn get_git_status(path: String) -> Result { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +#[derive(serde::Serialize)] +struct GitBranches { + branches: Vec, + current: String, +} + #[tauri::command] -async fn get_git_branches(path: String) -> Result, String> { +async fn get_git_branches(path: String) -> Result { let output = silent_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) .output() .map_err(|e| e.to_string())?; - let branches = String::from_utf8_lossy(&output.stdout) + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + + let branches: Vec = String::from_utf8_lossy(&output.stdout) .lines() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - Ok(branches) + // `symbolic-ref` returns the checked-out branch. Fails on detached HEAD, + // in which case we fall back to an empty string so the UI stays usable. + let current = silent_command("git") + .args(["symbolic-ref", "--short", "HEAD"]) + .current_dir(&path) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_default(); + + Ok(GitBranches { branches, current }) +} + +// Reject names that git would interpret as an option flag. Prevents callers +// from smuggling `--orphan`, `--detach`, etc. through the branch argument. +fn validate_branch_name(branch: &str) -> Result<(), String> { + if branch.is_empty() { + return Err("Branch name cannot be empty".to_string()); + } + if branch.starts_with('-') { + return Err("Branch name cannot start with '-'".to_string()); + } + Ok(()) +} + +#[tauri::command] +async fn git_checkout_branch(path: String, branch: String) -> Result { + validate_branch_name(&branch)?; + let output = silent_command("git") + .args(["switch", &branch]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_create_branch(path: String, branch: String) -> Result { + validate_branch_name(&branch)?; + let output = silent_command("git") + .args(["switch", "-c", &branch]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } } #[tauri::command] @@ -708,6 +778,8 @@ pub fn run() { git_init, get_git_status, get_git_branches, + git_checkout_branch, + git_create_branch, git_commit, git_add, git_unstage, diff --git a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx index ad64fdfe..1e52e655 100644 --- a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx +++ b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx @@ -173,9 +173,9 @@ const GitExplorerComponent: React.FC = () => { } setStagedChanges(staged); setGitChanges(unstaged); - const bl = await invoke("get_git_branches", { path: rootPath }); + const { branches: bl, current } = await invoke("get_git_branches", { path: rootPath }); setBranches(bl); - if (bl.length > 0) setCurrentBranch(bl[0]); + setCurrentBranch(current); } catch (err) { const errStr = String(err).toLowerCase(); const isNotGitRepoError = @@ -222,6 +222,36 @@ const GitExplorerComponent: React.FC = () => { }, [activeSidebarTab, rootPath, refreshGit]); const handleGitInit = async () => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_init", { path: rootPath }); setIsGitRepo(true); await refreshGit(); flash(t('git.status.init_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; + const handleCheckoutBranch = async (branch: string) => { + if (!rootPath || branch === currentBranch) { setShowBranchMenu(false); return; } + setGitLoading(true); + try { + await invoke("git_checkout_branch", { path: rootPath, branch }); + setShowBranchMenu(false); + await refreshGit(); + flash(t('git.status.checkout_success', { branch })); + } catch (e) { + flash(t('git.error', { message: String(e) })); + } finally { + setGitLoading(false); + } + }; + const handleCreateBranch = async () => { + const name = newBranchName.trim(); + if (!rootPath || !name) return; + setGitLoading(true); + try { + await invoke("git_create_branch", { path: rootPath, branch: name }); + setNewBranchName(""); + setShowBranchMenu(false); + await refreshGit(); + flash(t('git.status.checkout_success', { branch: name })); + } catch (e) { + flash(t('git.error', { message: String(e) })); + } finally { + setGitLoading(false); + } + }; const handleCommit = async () => { if (!rootPath || !commitMessage.trim()) return; setGitLoading(true); try { await invoke("git_commit", { path: rootPath, message: commitMessage }); setCommitMessage(""); await refreshGit(); flash(t('git.status.commit_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; const handlePush = async () => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_push", { path: rootPath }); flash(t('git.status.push_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; const handleStage = async (file: string) => { if (!rootPath) return; try { await invoke("git_add", { path: rootPath, files: [file] }); await refreshGit(); } catch (e) { flash(t('git.error', { message: String(e) })); } }; @@ -532,16 +562,43 @@ const GitExplorerComponent: React.FC = () => {
{showBranchMenu && (
- {branches.map((b) => )} + {branches.map((b) => { + const isCurrent = b === currentBranch; + return ( + + ); + })}
- setNewBranchName(e.target.value)} placeholder={t('git.new_branch')} - className="flex-1 bg-[#0e0e0e] border border-[#222] rounded-md px-2 py-1 text-[11px] text-white placeholder-[#444] focus:outline-none focus:border-[#444]" /> - + setNewBranchName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCreateBranch(); }} + placeholder={t('git.new_branch')} + className="flex-1 bg-[#0e0e0e] border border-[#222] rounded-md px-2 py-1 text-[11px] text-white placeholder-[#444] focus:outline-none focus:border-[#444]" + /> +
)} diff --git a/apps/desktop/src/api/builtin.l10n.ts b/apps/desktop/src/api/builtin.l10n.ts index 53754ed9..3af21548 100644 --- a/apps/desktop/src/api/builtin.l10n.ts +++ b/apps/desktop/src/api/builtin.l10n.ts @@ -107,6 +107,8 @@ export function registerBuiltinTranslations() { 'git.status.push_success': 'Push ✓', 'git.status.all_staged': 'All staged ✓', 'git.status.no_staged_changes': '⚠ No staged changes', + 'git.status.checkout_success': 'Switched to "{branch}" ✓', + 'git.branch.detached': '(detached HEAD)', 'git.explorer.safe_dir_title': 'Git detected a permission issue in this folder.', 'git.explorer.safe_dir_desc': 'Add "{path}" as a safe directory?\n\n(This runs: git config --global --add safe.directory)', 'git.explorer.delete_folder_confirm': 'Are you sure you want to delete folder "{name}" and all its contents?', @@ -438,6 +440,8 @@ export function registerBuiltinTranslations() { 'git.status.push_success': 'Push realizado ✓', 'git.status.all_staged': 'Todo preparado ✓', 'git.status.no_staged_changes': '⚠ No hay cambios preparados', + 'git.status.checkout_success': 'Cambiado a "{branch}" ✓', + 'git.branch.detached': '(HEAD desacoplado)', 'git.explorer.safe_dir_title': 'Git detectó un problema de permisos en esta carpeta.', 'git.explorer.safe_dir_desc': '¿Añadir "{path}" como directorio seguro?\n\n(Esto ejecuta: git config --global --add safe.directory)', 'git.explorer.delete_folder_confirm': '¿Estás seguro de que quieres eliminar la carpeta "{name}" y todo su contenido?', diff --git a/apps/desktop/src/api/tauri.ts b/apps/desktop/src/api/tauri.ts index cab5cf7c..6565d7b4 100644 --- a/apps/desktop/src/api/tauri.ts +++ b/apps/desktop/src/api/tauri.ts @@ -55,7 +55,9 @@ export interface TauriInvokeMap { "git_unstage": { args: { path: string; files: string[] }; return: void }; "git_add_safe_directory": { args: { path: string }; return: void }; "get_git_status": { args: { path: string }; return: string }; - "get_git_branches": { args: { path: string }; return: string[] }; + "get_git_branches": { args: { path: string }; return: { branches: string[]; current: string } }; + "git_checkout_branch": { args: { path: string; branch: string }; return: string }; + "git_create_branch": { args: { path: string; branch: string }; return: string }; "get_git_diff": { args: { path: string }; return: string }; "git_init": { args: { path: string }; return: string }; "git_commit": { args: { path: string; message: string }; return: string };