Branch menu UI and remote branch checkout
Add UI improvements and switching state for branch menu and implement remote-branch-aware checkout logic. Frontend: add CSS for disabled branch items, compact branch name layout, subtitles and messages; show switching indicator and disable branch actions while switching; display local/remote names and remote subtitles; surface branch switch errors and in-progress messages. Add state.branches.switchingTo and update event handlers to set/clear it. Backend (tauri): extend LocalRepoBranch with is_remote, remote_name, local_name; add git_ref_exists helper; enhance checkout_branch to detect local vs remote refs and automatically checkout or create tracking branches for remotes; parse refs to include remote branches (skip ones with existing local counterparts) and sort branches with current/local/remote order. Minor formatting tweaks.
This commit is contained in:
@@ -674,6 +674,11 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-menu-item:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-menu-item.active {
|
.branch-menu-item.active {
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -685,11 +690,35 @@
|
|||||||
border-color: transparent;
|
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 {
|
.branch-current-mark {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-menu-message {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.danger-subtle {
|
.danger-subtle {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-8
@@ -254,6 +254,7 @@ function currentRepositoryName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentBranchName() {
|
function currentBranchName() {
|
||||||
|
if (getState().sync.isDetached) return "Detached HEAD";
|
||||||
return getState().workingTree.branch || getState().viewer.branch || defaultBranchName;
|
return getState().workingTree.branch || getState().viewer.branch || defaultBranchName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,10 +544,23 @@ function diffTemplate(diffResult) {
|
|||||||
return `<div class="diff-preview diff-preview-inline">${renderDiff(diffResult.diff || "")}</div>`;
|
return `<div class="diff-preview diff-preview-inline">${renderDiff(diffResult.diff || "")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function branchOptionTemplate(branch) {
|
function branchDisplayName(branch) {
|
||||||
return `<button class="branch-menu-item ${branch.current ? "active" : ""}" data-branch-name="${escapeHtml(branch.name)}" type="button">
|
return branch.localName || branch.name;
|
||||||
<span>${escapeHtml(branch.name)}</span>
|
}
|
||||||
${branch.current ? `<span class="branch-current-mark">Current</span>` : ""}
|
|
||||||
|
function branchOptionTemplate(branch, switchingTo = "") {
|
||||||
|
const displayName = branchDisplayName(branch);
|
||||||
|
const isSwitching = switchingTo === branch.name || switchingTo === displayName;
|
||||||
|
const status = isSwitching ? "Switching..." : branch.current ? "Current" : branch.isRemote ? (branch.remoteName || "Remote") : "";
|
||||||
|
const title = branch.isRemote
|
||||||
|
? `Checkout ${displayName} as a local branch tracking ${branch.name}`
|
||||||
|
: `Checkout ${displayName}`;
|
||||||
|
return `<button class="branch-menu-item ${branch.current ? "active" : ""}" data-branch-name="${escapeHtml(branch.name)}" type="button" title="${escapeHtml(title)}" ${switchingTo ? "disabled" : ""}>
|
||||||
|
<span class="branch-menu-name">
|
||||||
|
<span>${escapeHtml(displayName)}</span>
|
||||||
|
${branch.isRemote ? `<span class="branch-menu-subtitle">Remote: ${escapeHtml(branch.name)}</span>` : ""}
|
||||||
|
</span>
|
||||||
|
${status ? `<span class="branch-current-mark">${escapeHtml(status)}</span>` : ""}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1413,7 +1427,7 @@ function dashboardView() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="gd-branch-wrap">
|
<div class="gd-branch-wrap">
|
||||||
<button id="branch-menu-btn" class="gd-toolbar-cell gd-branch-toolbar" type="button" ${!hasLocalRepo || state.branches.loading ? "disabled" : ""} title="${escapeHtml(displayBranchName)}">
|
<button id="branch-menu-btn" class="gd-toolbar-cell gd-branch-toolbar" type="button" ${!hasLocalRepo || state.branches.loading || state.branches.switchingTo ? "disabled" : ""} title="${escapeHtml(displayBranchName)}">
|
||||||
<span class="gd-cell-icon">${BRANCH_ICON}</span>
|
<span class="gd-cell-icon">${BRANCH_ICON}</span>
|
||||||
<span class="gd-cell-copy">
|
<span class="gd-cell-copy">
|
||||||
<span class="gd-cell-label">Current branch</span>
|
<span class="gd-cell-label">Current branch</span>
|
||||||
@@ -1424,11 +1438,13 @@ function dashboardView() {
|
|||||||
${state.branches.menuOpen ? `<div class="branch-menu">
|
${state.branches.menuOpen ? `<div class="branch-menu">
|
||||||
<div class="branch-menu-section">
|
<div class="branch-menu-section">
|
||||||
<div class="branch-menu-label">Switch Branch</div>
|
<div class="branch-menu-label">Switch Branch</div>
|
||||||
${(state.branches.items.length ? state.branches.items : [{ name: displayBranchName, current: true }]).map(branchOptionTemplate).join("")}
|
${state.branches.error ? `<div class="branch-menu-message danger-subtle">${escapeHtml(state.branches.error)}</div>` : ""}
|
||||||
|
${state.branches.switchingTo ? `<div class="branch-menu-message">Switching to ${escapeHtml(state.branches.switchingTo)}...</div>` : ""}
|
||||||
|
${(state.branches.items.length ? state.branches.items : [{ name: displayBranchName, localName: displayBranchName, current: true }]).map((branch) => branchOptionTemplate(branch, state.branches.switchingTo)).join("")}
|
||||||
</div>
|
</div>
|
||||||
<div class="branch-menu-section">
|
<div class="branch-menu-section">
|
||||||
<button class="branch-menu-action" data-branch-action="create" type="button">Create new branch…</button>
|
<button class="branch-menu-action" data-branch-action="create" type="button">Create new branch…</button>
|
||||||
<button class="branch-menu-action" data-branch-action="rename" data-branch-name="${escapeHtml(displayBranchName)}" type="button" ${!displayBranchName ? "disabled" : ""}>Rename current branch…</button>
|
<button class="branch-menu-action" data-branch-action="rename" data-branch-name="${escapeHtml(displayBranchName)}" type="button" ${!displayBranchName || state.sync.isDetached ? "disabled" : ""}>Rename current branch…</button>
|
||||||
<button class="branch-menu-action danger-subtle" data-branch-action="delete" type="button">Delete branch…</button>
|
<button class="branch-menu-action danger-subtle" data-branch-action="delete" type="button">Delete branch…</button>
|
||||||
</div>
|
</div>
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
@@ -2599,15 +2615,22 @@ function bindDashboardEvents() {
|
|||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selectedBranch = state.branches.items.find((item) => item.name === branch);
|
||||||
|
const displayName = selectedBranch ? branchDisplayName(selectedBranch) : branch;
|
||||||
|
state.branches.switchingTo = displayName;
|
||||||
|
render();
|
||||||
try {
|
try {
|
||||||
const result = await checkoutBranch(state.selectedRepoPath, branch, selectedGitPath());
|
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;
|
state.branches.menuOpen = false;
|
||||||
await refreshRepoData();
|
await refreshRepoData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
gitOutput = `Branch switch failed: ${errorMessage(error)}`;
|
gitOutput = `Branch switch failed: ${errorMessage(error)}`;
|
||||||
state.branches.menuOpen = false;
|
state.branches.menuOpen = false;
|
||||||
render();
|
render();
|
||||||
|
} finally {
|
||||||
|
state.branches.switchingTo = "";
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const state = {
|
|||||||
items: [],
|
items: [],
|
||||||
createName: "",
|
createName: "",
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
|
switchingTo: "",
|
||||||
dialog: {
|
dialog: {
|
||||||
mode: "",
|
mode: "",
|
||||||
target: "",
|
target: "",
|
||||||
|
|||||||
+99
-10
@@ -37,6 +37,9 @@ struct ServerConnectionResult {
|
|||||||
struct LocalRepoBranch {
|
struct LocalRepoBranch {
|
||||||
name: String,
|
name: String,
|
||||||
current: bool,
|
current: bool,
|
||||||
|
is_remote: bool,
|
||||||
|
remote_name: Option<String>,
|
||||||
|
local_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -812,6 +815,21 @@ fn normalize_reference(reference: &str) -> Result<String, String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_ref_exists(repo_path: &str, git_path: Option<String>, 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 {
|
fn treeish(reference: &str, path: &str) -> String {
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
reference.to_string()
|
reference.to_string()
|
||||||
@@ -1393,6 +1411,35 @@ fn checkout_branch(
|
|||||||
) -> Result<GitCommandResult, String> {
|
) -> Result<GitCommandResult, String> {
|
||||||
let repo_path = validate_repo_dir(&repo_path)?;
|
let repo_path = validate_repo_dir(&repo_path)?;
|
||||||
let branch = normalize_reference(&branch)?;
|
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(
|
ensure_git_success(run_git_command(
|
||||||
Some(&repo_path),
|
Some(&repo_path),
|
||||||
git_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()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|line| !line.is_empty() && !line.ends_with("/HEAD"))
|
.filter(|line| !line.is_empty() && !line.ends_with("/HEAD"))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let local_names = ref_names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ref_name| ref_name.strip_prefix("refs/heads/"))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
let mut branches = ref_names
|
||||||
|
.iter()
|
||||||
.filter_map(|ref_name| {
|
.filter_map(|ref_name| {
|
||||||
ref_name
|
if let Some(name) = ref_name.strip_prefix("refs/heads/") {
|
||||||
.strip_prefix("refs/heads/")
|
return Some(LocalRepoBranch {
|
||||||
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
|
|
||||||
})
|
|
||||||
.map(|name| LocalRepoBranch {
|
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
current: current.as_deref() == Some(name),
|
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(),
|
||||||
})
|
})
|
||||||
.collect();
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
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)
|
Ok(branches)
|
||||||
}
|
}
|
||||||
@@ -1920,7 +2006,11 @@ fn local_repo_file(
|
|||||||
let raw = run_git_output(
|
let raw = run_git_output(
|
||||||
repo_path.trim(),
|
repo_path.trim(),
|
||||||
git_path,
|
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);
|
let (preview_mime, preview_base64) = image_preview_pair(&path, &raw);
|
||||||
return Ok(LocalRepoFile {
|
return Ok(LocalRepoFile {
|
||||||
@@ -2033,8 +2123,7 @@ pub fn run() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window
|
let _ = window.set_background_color(Some(tauri::utils::config::Color(0, 0, 0, 0)));
|
||||||
.set_background_color(Some(tauri::utils::config::Color(0, 0, 0, 0)));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user