From b0245fb18fba493541536994c9322f29ad499f0a Mon Sep 17 00:00:00 2001 From: Matias Palma Date: Sun, 19 Apr 2026 01:37:27 -0400 Subject: [PATCH 1/2] fix: wire branch selection and creation in git explorer The branch dropdown rendered branches but had no click handlers, so switching branches did nothing. Current branch was set to the first entry from `git branch` (alphabetical), not the actual HEAD, making it appear out of sync with the working tree. - get_git_branches now returns { branches, current } using `git symbolic-ref --short HEAD` for the real current branch - Add git_checkout_branch and git_create_branch tauri commands - Wire onClick on branch items and the create (+) button - Mark the active branch in the dropdown Closes TrixtyAI/ide#35 --- apps/desktop/src-tauri/src/lib.rs | 64 ++++++++++++++++++- .../GitExplorerComponent.tsx | 62 ++++++++++++++++-- apps/desktop/src/api/builtin.l10n.ts | 2 + apps/desktop/src/api/tauri.ts | 4 +- 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cbb2cc2..e7c3bade 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -322,21 +322,77 @@ 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 }) +} + +#[tauri::command] +async fn git_checkout_branch(path: String, branch: String) -> Result { + let output = silent_command("git") + .args(["checkout", &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 { + let output = silent_command("git") + .args(["checkout", "-b", &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 +764,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..14750df9 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 || bl[0] || ""); } 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) })); } }; @@ -537,11 +567,31 @@ const GitExplorerComponent: React.FC = () => { {showBranchMenu && (
- {branches.map((b) => )} + {branches.map((b) => ( + + ))}
- 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..6da9d886 100644 --- a/apps/desktop/src/api/builtin.l10n.ts +++ b/apps/desktop/src/api/builtin.l10n.ts @@ -107,6 +107,7 @@ 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.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 +439,7 @@ 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.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 }; From d85cfd648300200f4cb056863de48b466332ffe3 Mon Sep 17 00:00:00 2001 From: Matias Palma Date: Sun, 19 Apr 2026 01:54:09 -0400 Subject: [PATCH 2/2] fix: address review feedback on branch selection - Reject branch names starting with `-` to prevent option injection into `git switch` (e.g. `--orphan`, `--detach`). - Use `git switch` / `git switch -c` instead of `git checkout` for clearer intent and no path-checkout ambiguity. - Drop the `bl[0]` fallback for currentBranch so detached HEAD stays empty instead of impersonating the first branch alphabetically. - Render `git.branch.detached` label when HEAD is detached, replacing the misleading hardcoded "main" fallback in the dropdown header. - A11y: add `aria-current` on the active branch item and `aria-label` on the create-branch (+) icon button. --- apps/desktop/src-tauri/src/lib.rs | 18 +++++++++-- .../GitExplorerComponent.tsx | 31 ++++++++++++------- apps/desktop/src/api/builtin.l10n.ts | 2 ++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e7c3bade..d94a32b7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -365,10 +365,23 @@ async fn get_git_branches(path: String) -> Result { 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(["checkout", &branch]) + .args(["switch", &branch]) .current_dir(&path) .output() .map_err(|e| e.to_string())?; @@ -382,8 +395,9 @@ async fn git_checkout_branch(path: String, branch: String) -> Result Result { + validate_branch_name(&branch)?; let output = silent_command("git") - .args(["checkout", "-b", &branch]) + .args(["switch", "-c", &branch]) .current_dir(&path) .output() .map_err(|e| e.to_string())?; diff --git a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx index 14750df9..1e52e655 100644 --- a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx +++ b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx @@ -175,7 +175,7 @@ const GitExplorerComponent: React.FC = () => { setGitChanges(unstaged); const { branches: bl, current } = await invoke("get_git_branches", { path: rootPath }); setBranches(bl); - setCurrentBranch(current || bl[0] || ""); + setCurrentBranch(current); } catch (err) { const errStr = String(err).toLowerCase(); const isNotGitRepoError = @@ -562,21 +562,27 @@ const GitExplorerComponent: React.FC = () => {
{showBranchMenu && (
- {branches.map((b) => ( - - ))} + {branches.map((b) => { + const isCurrent = b === currentBranch; + return ( + + ); + })}
{