Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,21 +322,91 @@ async fn get_git_status(path: String) -> Result<String, String> {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

#[derive(serde::Serialize)]
struct GitBranches {
branches: Vec<String>,
current: String,
}

#[tauri::command]
async fn get_git_branches(path: String) -> Result<Vec<String>, String> {
async fn get_git_branches(path: String) -> Result<GitBranches, String> {
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> = 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<String, String> {
validate_branch_name(&branch)?;
let output = silent_command("git")
.args(["switch", &branch])
.current_dir(&path)
.output()
.map_err(|e| e.to_string())?;
Comment thread
matiaspalmac marked this conversation as resolved.

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<String, String> {
validate_branch_name(&branch)?;
let output = silent_command("git")
.args(["switch", "-c", &branch])
.current_dir(&path)
.output()
.map_err(|e| e.to_string())?;
Comment thread
matiaspalmac marked this conversation as resolved.

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]
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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) })); } };
Expand Down Expand Up @@ -532,16 +562,43 @@ const GitExplorerComponent: React.FC = () => {
<div className="px-3 py-2 border-b border-[#1a1a1a]">
<button onClick={() => setShowBranchMenu(!showBranchMenu)} className="flex items-center gap-2 w-full text-[12px] text-white hover:bg-white/[0.04] rounded-lg p-1.5 transition-colors">
<GitBranch size={13} className="text-[#666]" />
<span className="font-medium truncate">{currentBranch || "main"}</span>
<span className={`font-medium truncate ${!currentBranch ? 'text-[#888] italic' : ''}`}>
{currentBranch || t('git.branch.detached')}
</span>
{showBranchMenu ? <ChevronUp size={11} className="ml-auto text-[#555]" /> : <ChevronDown size={11} className="ml-auto text-[#555]" />}
</button>
{showBranchMenu && (
<div className="mt-1 bg-[#141414] border border-[#222] rounded-xl overflow-hidden">
{branches.map((b) => <button key={b} className="w-full text-left px-3 py-1.5 text-[11px] text-[#999] hover:bg-white/[0.04] transition-colors">{b}</button>)}
{branches.map((b) => {
const isCurrent = b === currentBranch;
return (
<button
key={b}
onClick={() => handleCheckoutBranch(b)}
disabled={gitLoading}
aria-current={isCurrent ? "true" : undefined}
className={`w-full text-left px-3 py-1.5 text-[11px] hover:bg-white/[0.04] transition-colors disabled:opacity-50 ${isCurrent ? 'text-white font-medium' : 'text-[#999]'}`}
>
{isCurrent ? `● ${b}` : b}
</button>
);
})}
<div className="border-t border-[#222] p-2 flex gap-1">
<input value={newBranchName} onChange={(e) => 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]" />
<button className="p-1 bg-white text-black rounded-md hover:bg-white/90 transition-colors"><Plus size={13} /></button>
<input
value={newBranchName}
onChange={(e) => 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]"
/>
<button
onClick={handleCreateBranch}
disabled={gitLoading || !newBranchName.trim()}
aria-label={t('git.new_branch')}
className="p-1 bg-white text-black rounded-md hover:bg-white/90 transition-colors disabled:opacity-40"
>
<Plus size={13} />
</button>
Comment thread
matiaspalmac marked this conversation as resolved.
</div>
</div>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/api/builtin.l10n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand Down Expand Up @@ -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?',
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading