diff --git a/frontend/css/components.css b/frontend/css/components.css index 87d0fbc..592ddda 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -674,6 +674,11 @@ font-size: 13px; } +.branch-menu-item:disabled { + cursor: default; + opacity: 0.72; +} + .branch-menu-item.active { background: var(--accent-subtle); color: var(--accent); @@ -685,11 +690,35 @@ border-color: transparent; } +.branch-menu-item:disabled:hover { + background: transparent; +} + +.branch-menu-name { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.branch-menu-name > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.branch-menu-subtitle, .branch-current-mark { color: var(--text-muted); font-size: 11px; } +.branch-menu-message { + padding: 6px 8px; + color: var(--text-muted); + font-size: 12px; +} + .danger-subtle { color: var(--danger); } diff --git a/frontend/js/app.js b/frontend/js/app.js index d89224d..0d2de8f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -254,6 +254,7 @@ function currentRepositoryName() { } function currentBranchName() { + if (getState().sync.isDetached) return "Detached HEAD"; return getState().workingTree.branch || getState().viewer.branch || defaultBranchName; } @@ -543,10 +544,23 @@ function diffTemplate(diffResult) { return `
${renderDiff(diffResult.diff || "")}
`; } -function branchOptionTemplate(branch) { - return ``; } @@ -1413,7 +1427,7 @@ function dashboardView() {
- - +
` : ""} @@ -2599,15 +2615,22 @@ function bindDashboardEvents() { render(); return; } + const selectedBranch = state.branches.items.find((item) => item.name === branch); + const displayName = selectedBranch ? branchDisplayName(selectedBranch) : branch; + state.branches.switchingTo = displayName; + render(); try { const result = await checkoutBranch(state.selectedRepoPath, branch, selectedGitPath()); - gitOutput = `${result.command}\n\n${result.stdout || "Branch switched."}\n${result.stderr || ""}`; + gitOutput = `${result.command}\n\n${result.stdout || `Switched to ${displayName}.`}\n${result.stderr || ""}`; state.branches.menuOpen = false; await refreshRepoData(); } catch (error) { gitOutput = `Branch switch failed: ${errorMessage(error)}`; state.branches.menuOpen = false; render(); + } finally { + state.branches.switchingTo = ""; + render(); } }); }); diff --git a/frontend/js/state.js b/frontend/js/state.js index dbd6ce1..8aafc03 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -57,6 +57,7 @@ const state = { items: [], createName: "", menuOpen: false, + switchingTo: "", dialog: { mode: "", target: "", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b82e4c3..f4ee422 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,9 @@ struct ServerConnectionResult { struct LocalRepoBranch { name: String, current: bool, + is_remote: bool, + remote_name: Option, + local_name: String, } #[derive(Serialize)] @@ -812,6 +815,21 @@ fn normalize_reference(reference: &str) -> Result { Ok(trimmed.to_string()) } +fn git_ref_exists(repo_path: &str, git_path: Option, ref_name: &str) -> bool { + run_git_command( + Some(repo_path), + git_path, + vec![ + "show-ref".to_string(), + "--verify".to_string(), + "--quiet".to_string(), + ref_name.to_string(), + ], + ) + .map(|result| result.success) + .unwrap_or(false) +} + fn treeish(reference: &str, path: &str) -> String { if path.is_empty() { reference.to_string() @@ -1393,6 +1411,35 @@ fn checkout_branch( ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let branch = normalize_reference(&branch)?; + let local_ref = format!("refs/heads/{branch}"); + if git_ref_exists(&repo_path, git_path.clone(), &local_ref) { + return ensure_git_success(run_git_command( + Some(&repo_path), + git_path, + vec!["checkout".to_string(), branch], + )?); + } + + let remote_ref = format!("refs/remotes/{branch}"); + if git_ref_exists(&repo_path, git_path.clone(), &remote_ref) { + if let Some((_, local_branch)) = branch.split_once('/') { + let tracking_local_ref = format!("refs/heads/{local_branch}"); + if git_ref_exists(&repo_path, git_path.clone(), &tracking_local_ref) { + return ensure_git_success(run_git_command( + Some(&repo_path), + git_path, + vec!["checkout".to_string(), local_branch.to_string()], + )?); + } + } + + return ensure_git_success(run_git_command( + Some(&repo_path), + git_path, + vec!["checkout".to_string(), "--track".to_string(), branch], + )?); + } + ensure_git_success(run_git_command( Some(&repo_path), git_path, @@ -1806,20 +1853,59 @@ fn local_repo_branches( ], )?; - let branches = String::from_utf8_lossy(&output) + let ref_names = String::from_utf8_lossy(&output) .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.ends_with("/HEAD")) + .map(str::to_string) + .collect::>(); + + let local_names = ref_names + .iter() + .filter_map(|ref_name| ref_name.strip_prefix("refs/heads/")) + .map(str::to_string) + .collect::>(); + + let mut branches = ref_names + .iter() .filter_map(|ref_name| { - ref_name - .strip_prefix("refs/heads/") - .or_else(|| ref_name.strip_prefix("refs/remotes/")) + if let Some(name) = ref_name.strip_prefix("refs/heads/") { + return Some(LocalRepoBranch { + name: name.to_string(), + current: current.as_deref() == Some(name), + is_remote: false, + remote_name: None, + local_name: name.to_string(), + }); + } + + let name = ref_name.strip_prefix("refs/remotes/")?; + let (remote_name, local_name) = name.split_once('/')?; + if local_names.contains(local_name) { + return None; + } + + Some(LocalRepoBranch { + name: name.to_string(), + current: false, + is_remote: true, + remote_name: Some(remote_name.to_string()), + local_name: local_name.to_string(), + }) }) - .map(|name| LocalRepoBranch { - name: name.to_string(), - current: current.as_deref() == Some(name), - }) - .collect(); + .collect::>(); + + branches.sort_by(|a, b| { + b.current + .cmp(&a.current) + .then_with(|| a.is_remote.cmp(&b.is_remote)) + .then_with(|| { + a.local_name + .to_lowercase() + .cmp(&b.local_name.to_lowercase()) + }) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); Ok(branches) } @@ -1920,7 +2006,11 @@ fn local_repo_file( let raw = run_git_output( repo_path.trim(), git_path, - vec!["show".to_string(), "--no-ext-diff".to_string(), spec.clone()], + vec![ + "show".to_string(), + "--no-ext-diff".to_string(), + spec.clone(), + ], )?; let (preview_mime, preview_base64) = image_preview_pair(&path, &raw); return Ok(LocalRepoFile { @@ -2033,8 +2123,7 @@ pub fn run() { .setup(|app| { use tauri::Manager; if let Some(window) = app.get_webview_window("main") { - let _ = window - .set_background_color(Some(tauri::utils::config::Color(0, 0, 0, 0))); + let _ = window.set_background_color(Some(tauri::utils::config::Color(0, 0, 0, 0))); } Ok(()) })