Files
GitLite/src/lib/components/BranchPanel.svelte
T
Christoph 216b552e61 refactor(BranchPanel): add per-group accent styling to branch group headers
Add local/remote classes to the group header buttons and introduce a
--group-accent CSS variable used with color-mix for background, text tint,
hover state, and an inset accent shadow. Local headers use --color-info and
remote headers use --color-accent.

This is a visual-only change (no toggle/behavior changes): group headers now
have a subtle, distinct accent for local vs. remote sections instead of a
uniform muted surface.
2026-09-18 20:41:53 +02:00

1106 lines
36 KiB
Svelte

<script lang="ts">
import {
Check,
ChevronDown,
ChevronRight,
CircleDot,
Cloud,
CloudOff,
Ellipsis,
Folder,
FolderOpen,
GitBranch,
GitCompare,
GitMerge,
Globe,
HardDrive,
Laptop,
Link2,
Pencil,
Plus,
Search,
Trash2,
X,
} from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { t } from "../i18n.svelte";
type Scope = "local" | "remote";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
interface BranchFolderNode {
kind: "folder";
id: string;
name: string;
children: BranchTreeNode[];
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
folders: Map<string, BranchFolderNode>;
}
interface BranchLeafNode {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
}
type BranchRow = BranchFolderRow | BranchLeafRow;
interface BranchFolderRow {
kind: "folder";
id: string;
name: string;
depth: number;
branchCount: number;
current: boolean;
branches: GitBranchInfo[];
scope: Scope;
}
interface BranchLeafRow {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
depth: number;
scope: Scope;
}
interface Props {
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onCompareBranch: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
onCompareBranch = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateWorktree = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
const VIEW_STATE_KEY = "gitlite.branchPanelView.v2";
const INDENT = 14;
interface StoredViewState {
localOpen?: boolean;
remoteOpen?: boolean;
collapsedFolders?: string[];
}
function loadViewState(): StoredViewState {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(VIEW_STATE_KEY) ?? "{}");
return parsed && typeof parsed === "object" ? (parsed as StoredViewState) : {};
} catch {
return {};
}
}
const storedView = loadViewState();
let localOpen = $state(storedView.localOpen ?? true);
let remoteOpen = $state(storedView.remoteOpen ?? false);
let collapsedBranchFolders = $state<Set<string>>(new Set(storedView.collapsedFolders ?? []));
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let filterText = $state("");
let filterInput = $state<HTMLInputElement | null>(null);
let scrollElement = $state<HTMLElement | null>(null);
let flashId = $state<string | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
$effect(() => {
const snapshot: StoredViewState = {
localOpen,
remoteOpen,
collapsedFolders: [...collapsedBranchFolders],
};
try {
localStorage.setItem(VIEW_STATE_KEY, JSON.stringify(snapshot));
} catch {
/* Optional preference. */
}
});
let query = $derived(filterText.trim().toLowerCase());
let filtering = $derived(query.length > 0);
let currentBranch = $derived(localBranches.find((branch) => branch.current) ?? null);
let remoteBranchNames = $derived(new Set(remoteBranches.map((branch) => branch.name)));
let trackingLocalByRemote = $derived(
new Map(localBranches.flatMap((branch) => (branch.upstream ? [[branch.upstream, branch.name] as const] : []))),
);
let visibleLocal = $derived(filtering ? localBranches.filter(matchesFilter) : localBranches);
let visibleRemote = $derived(filtering ? remoteBranches.filter(matchesFilter) : remoteBranches);
let localBranchRows = $derived(buildBranchRows("local", visibleLocal));
let remoteBranchRows = $derived(buildBranchRows("remote", visibleRemote));
let showLocal = $derived(filtering ? visibleLocal.length > 0 : localOpen);
let showRemote = $derived(filtering ? visibleRemote.length > 0 : remoteOpen);
function matchesFilter(branch: GitBranchInfo) {
return branch.name.toLowerCase().includes(query);
}
function highlightParts(text: string) {
if (!filtering) return [{ text, match: false }];
const index = text.toLowerCase().indexOf(query);
if (index < 0) return [{ text, match: false }];
return [
{ text: text.slice(0, index), match: false },
{ text: text.slice(index, index + query.length), match: true },
{ text: text.slice(index + query.length), match: false },
].filter((part) => part.text.length > 0);
}
function createFolder(id: string, name: string): BranchFolderNode {
return { kind: "folder", id, name, children: [], branchCount: 0, current: false, branches: [], folders: new Map() };
}
function branchId(scope: Scope, name: string) {
return `${scope}:branch:${name}`;
}
function folderId(scope: Scope, path: string) {
return `${scope}:folder:${path}`;
}
function buildBranchRows(scope: Scope, branchList: GitBranchInfo[]): BranchRow[] {
const root = createFolder(`${scope}:root`, "");
for (const branch of branchList) {
const parts = branch.name.split("/").filter(Boolean);
const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name;
const folderParts = parts.slice(0, -1);
let parent = root;
for (let index = 0; index < folderParts.length; index += 1) {
const folderName = folderParts[index];
let folder = parent.folders.get(folderName);
if (!folder) {
folder = createFolder(folderId(scope, folderParts.slice(0, index + 1).join("/")), folderName);
parent.folders.set(folderName, folder);
parent.children.push(folder);
}
folder.branchCount += 1;
folder.current ||= branch.current;
folder.branches.push(branch);
parent = folder;
}
parent.children.push({ kind: "branch", id: branchId(scope, branch.name), branch, displayName });
}
sortBranchNodes(root.children);
const rows: BranchRow[] = [];
flattenBranchNodes(root.children, rows, 0, scope);
return rows;
}
function sortBranchNodes(nodes: BranchTreeNode[]) {
nodes.sort((left, right) => {
if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1;
const leftName = left.kind === "folder" ? left.name : left.displayName;
const rightName = right.kind === "folder" ? right.name : right.displayName;
return leftName.localeCompare(rightName, undefined, { sensitivity: "base" });
});
for (const node of nodes) {
if (node.kind === "folder") sortBranchNodes(node.children);
}
}
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scope: Scope) {
for (const node of nodes) {
if (node.kind === "folder") {
rows.push({
kind: "folder",
id: node.id,
name: node.name,
depth,
branchCount: node.branchCount,
current: node.current,
branches: node.branches,
scope,
});
if (isBranchFolderOpen(node.id)) {
flattenBranchNodes(node.children, rows, depth + 1, scope);
}
} else {
rows.push({ kind: "branch", id: node.id, branch: node.branch, displayName: node.displayName, depth, scope });
}
}
}
function isBranchFolderOpen(id: string) {
return filtering || !collapsedBranchFolders.has(id);
}
function toggleBranchFolder(id: string) {
if (filtering) return;
const next = new Set(collapsedBranchFolders);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedBranchFolders = next;
}
function isRemoteRoot(row: BranchFolderRow) {
return row.scope === "remote" && row.depth === 0;
}
type Tracking =
| { kind: "tracked"; upstream: string }
| { kind: "gone"; upstream: string }
| { kind: "local" }
| { kind: "remote-tracked"; local: string }
| { kind: "remote" };
function trackingFor(branch: GitBranchInfo): Tracking {
if (branch.remote) {
const local = trackingLocalByRemote.get(branch.name);
return local ? { kind: "remote-tracked", local } : { kind: "remote" };
}
if (!branch.upstream) return { kind: "local" };
return remoteBranchNames.has(branch.upstream)
? { kind: "tracked", upstream: branch.upstream }
: { kind: "gone", upstream: branch.upstream };
}
function branchTitle(branch: GitBranchInfo) {
const tracking = trackingFor(branch);
const lines = [branch.name];
if (branch.current) lines.push(t("branches.tipCurrent"));
if (tracking.kind === "tracked") lines.push(t("branches.tipTracks", { upstream: tracking.upstream }));
if (tracking.kind === "gone") lines.push(t("branches.tipGone", { upstream: tracking.upstream }));
if (tracking.kind === "local") lines.push(t("branches.tipLocalOnly"));
if (tracking.kind === "remote-tracked") lines.push(t("branches.tipCheckedOut", { name: tracking.local }));
if (!branch.current) lines.push(t("branches.tipDoubleClick"));
return lines.join("\n");
}
async function revealCurrentBranch() {
const branch = currentBranch;
if (!branch) return;
filterText = "";
localOpen = true;
const parts = branch.name.split("/").filter(Boolean).slice(0, -1);
if (parts.length > 0) {
const next = new Set(collapsedBranchFolders);
for (let index = 0; index < parts.length; index += 1) {
next.delete(folderId("local", parts.slice(0, index + 1).join("/")));
}
collapsedBranchFolders = next;
}
await tick();
const id = branchId("local", branch.name);
const element = scrollElement?.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`);
element?.scrollIntoView({ block: "center", behavior: "smooth" });
flashId = id;
setTimeout(() => {
if (flashId === id) flashId = null;
}, 900);
}
function openCreateForm() {
if (!hasRepository || isBusy) return;
if (collapsed) onToggleCollapsed();
createOpen = true;
queueMicrotask(() => createInput?.focus());
}
function closeCreateForm() {
createOpen = false;
newBranchName = "";
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
const value = newBranchName.trim();
if (!value || !hasRepository || isBusy) return;
await onCreateBranch(value);
newBranchName = "";
createOpen = false;
localOpen = true;
}
function handleFilterKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && filterText) {
event.stopPropagation();
filterText = "";
}
}
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
if (branch.current || isBusy) return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest("button")) return;
onCheckout(branch);
}
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
async function showBranchMenuAt(branch: GitBranchInfo, x: number, y: number) {
if (isBusy) return;
contextFolder = null;
contextBranch = branch;
contextMenuX = x + 2;
contextMenuY = y + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, x, y);
contextMenuX = position.x;
contextMenuY = position.y;
}
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
void showBranchMenuAt(branch, event.clientX, event.clientY);
}
function openBranchMenuFromButton(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (contextBranch === branch) {
closeBranchContextMenu();
return;
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
void showBranchMenuAt(branch, rect.right - 184, rect.bottom);
}
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
event.preventDefault();
event.stopPropagation();
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
contextBranch = null;
contextFolder = folder;
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextFolder !== folder) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
function closeBranchContextMenu() {
contextBranch = null;
contextFolder = null;
}
async function renameContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
await onRenameBranch(branch);
}
function compareContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
onCompareBranch(branch);
}
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
if (branch.remote) await onDeleteRemoteBranch(branch);
else await onDeleteBranch(branch);
}
async function deleteContextFolder() {
const folder = contextFolder;
if (!folder || isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
closeBranchContextMenu();
await onDeleteBranchFolder(folder.name, folder.branches, folder.depth);
}
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
if (!branch) return;
await onCreateWorktree(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
}
</script>
<svelte:window onclick={closeBranchContextMenu} onkeydown={handleWindowKeydown} oncontextmenucapture={closeBranchContextMenu} />
{#snippet branchRows(rows: BranchRow[])}
{#each rows as row (row.id)}
{#if row.kind === "folder"}
{@const open = isBranchFolderOpen(row.id)}
<button
class="bp-row bp-folder"
class:has-current={row.current}
class:remote-root={isRemoteRoot(row)}
style={`--depth: ${row.depth}; --indent: ${INDENT}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={open}
title={row.branchCount === 1 ? t("branches.folderTitleOne", { name: row.name }) : t("branches.folderTitle", { name: row.name, count: row.branchCount })}
>
<span class="bp-chevron" aria-hidden="true">
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
</span>
<span class="bp-icon" aria-hidden="true">
{#if isRemoteRoot(row)}
<Globe size={13} />
{:else if open}
<FolderOpen size={13} />
{:else}
<Folder size={13} />
{/if}
</span>
<span class="bp-name">{row.name}</span>
{#if row.current && !open}
<span class="bp-current-dot" title={t("branches.containsCurrent")}></span>
{/if}
<span class="bp-count">{row.branchCount}</span>
</button>
{:else}
{@const tracking = trackingFor(row.branch)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bp-row bp-branch"
class:current={row.branch.current}
class:flash={flashId === row.id}
class:menu-open={contextBranch === row.branch}
data-row-id={row.id}
style={`--depth: ${row.depth}; --indent: ${INDENT}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={branchTitle(row.branch)}
>
<span class="bp-chevron" aria-hidden="true"></span>
<span class="bp-icon" aria-hidden="true">
{#if row.branch.current}<CircleDot size={13} />{:else}<GitBranch size={13} />{/if}
</span>
<span class="bp-name">
{#each highlightParts(row.displayName) as part, index (index)}
{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}
{/each}
</span>
<span class="bp-meta">
{#if tracking.kind === "tracked"}
<span class="bp-track" aria-label={t("branches.labelTracks", { upstream: tracking.upstream })}><Cloud size={11} /></span>
{:else if tracking.kind === "gone"}
<span class="bp-track gone" aria-label={t("branches.labelGone")}><CloudOff size={11} /></span>
{:else if tracking.kind === "local"}
<span class="bp-track local" aria-label={t("branches.labelLocalOnly")}><Laptop size={11} /></span>
{:else if tracking.kind === "remote-tracked"}
<span class="bp-track linked" aria-label={t("branches.labelCheckedOut", { name: tracking.local })}><Link2 size={11} /></span>
{/if}
{#if row.branch.current}
<span class="bp-head-tag">HEAD</span>
{/if}
</span>
<button
class="bp-more"
type="button"
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
disabled={isBusy}
title={t("branches.actions")}
aria-label={t("branches.actionsFor", { name: row.branch.name })}
>
<Ellipsis size={13} aria-hidden="true" />
</button>
</div>
{/if}
{/each}
{/snippet}
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label={t("branches.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />{t("branches.title")}</h2>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title={t("branches.create")}
aria-label={t("branches.create")}
>
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{branches.length}</span>
<button
class="branch-create-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? t("branches.expand") : t("branches.collapse")}
aria-label={collapsed ? t("branches.expandPanel") : t("branches.collapsePanel")}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
</div>
</div>
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">{t("branches.openRepo")}</p>
{:else}
<div class="bp-body">
<div class="bp-top">
{#if createOpen}
<form class="branch-create-form bp-create" onsubmit={submitCreate}>
<GitBranch size={15} aria-hidden="true" />
<input
bind:this={createInput}
bind:value={newBranchName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder={t("branches.namePlaceholder")}
aria-label={t("branches.nameLabel")}
onkeydown={(event) => { if (event.key === "Escape") closeCreateForm(); }}
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title={t("branches.createAction")}>
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title={t("common.cancel")}>
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if currentBranch}
{@const tracking = trackingFor(currentBranch)}
<button class="bp-current" type="button" onclick={revealCurrentBranch} title={t("branches.revealCurrent")}>
<span class="bp-current-icon" aria-hidden="true"><CircleDot size={14} /></span>
<span class="bp-current-text">
<strong>{currentBranch.name}</strong>
<small>
{#if tracking.kind === "tracked"}
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
{:else if tracking.kind === "gone"}
<CloudOff size={10} aria-hidden="true" /> {t("branches.upstreamGone", { upstream: tracking.upstream })}
{:else}
<Laptop size={10} aria-hidden="true" /> {t("branches.notPublished")}
{/if}
</small>
</span>
</button>
{/if}
<label class="bp-filter">
<Search size={13} aria-hidden="true" />
<input
bind:this={filterInput}
bind:value={filterText}
onkeydown={handleFilterKeydown}
type="text"
autocomplete="off"
spellcheck="false"
placeholder={t("branches.filter")}
aria-label={t("branches.filter")}
/>
{#if filterText}
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title={t("branches.filterClear")} aria-label={t("branches.filterClear")}>
<X size={12} aria-hidden="true" />
</button>
{/if}
</label>
</div>
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label={t("branches.listLabel")}>
<div class="bp-group">
<button
class="bp-group-head local"
type="button"
onclick={() => { if (!filtering) localOpen = !localOpen; }}
aria-expanded={showLocal}
>
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Laptop size={12} aria-hidden="true" />
<span>{t("common.local")}</span>
<span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
</button>
{#if showLocal}
{#if localBranches.length === 0}
<div class="bp-empty">{t("branches.emptyLocal")}</div>
{:else}
{@render branchRows(localBranchRows)}
{/if}
{/if}
</div>
<div class="bp-group">
<button
class="bp-group-head remote"
type="button"
onclick={() => { if (!filtering) remoteOpen = !remoteOpen; }}
aria-expanded={showRemote}
>
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Cloud size={12} aria-hidden="true" />
<span>{t("common.remote")}</span>
<span class="bp-group-count">{filtering ? `${visibleRemote.length}/${remoteBranches.length}` : remoteBranches.length}</span>
</button>
{#if showRemote}
{#if remoteBranches.length === 0}
<div class="bp-empty">{t("branches.emptyRemote")}</div>
{:else}
{@render branchRows(remoteBranchRows)}
{/if}
{/if}
</div>
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
<div class="bp-empty">{t("branches.noMatch", { query: filterText.trim() })}</div>
{/if}
</div>
</div>
{/if}
{#if contextBranch}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={t("branches.actionsFor", { name: contextBranch.name })}
>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
{t("common.checkout")}
</button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
{t("branches.menuCompare")}
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
{t("branches.menuMerge")}
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
{t("branches.menuRebase")}
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
{t("branches.menuWorktree")}
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" />
{contextBranch.remote ? t("branches.menuRenameRemote") : t("common.rename")}
</button>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? t("branches.cannotDeleteCurrent") : contextBranch.remote ? t("branches.deleteRemoteBranch") : t("branches.deleteLocalBranch")}
>
<Trash2 size={14} aria-hidden="true" />
{contextBranch.remote ? t("branches.menuDeleteRemote") : t("common.delete")}
</button>
</div>
{/if}
{#if contextFolder}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={t("branches.folderActionsFor", { name: contextFolder.name })}
>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? t("branches.folderKeepsCurrent") : t("branches.folderDeleteHint")}
>
<Trash2 size={14} aria-hidden="true" />
{t("branches.deleteFolder", { count: contextFolder.branches.filter((branch) => !branch.current).length })}
</button>
</div>
{/if}
</section>
<style>
.bp-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.bp-top {
display: grid;
gap: 6px;
padding: 8px 8px 6px;
border-bottom: 1px solid var(--color-border-subtle);
}
.bp-top :global(.bp-create) { margin-bottom: 0; }
/* Current branch card */
.bp-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-height: 0;
padding: 6px 8px;
border: 1px solid color-mix(in srgb, var(--color-success) 26%, transparent);
border-radius: 7px;
background: linear-gradient(90deg, color-mix(in srgb, var(--color-success) 11%, transparent), transparent 85%);
color: var(--color-ink);
text-align: left;
}
.bp-current:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-success) 42%, transparent);
background: linear-gradient(90deg, color-mix(in srgb, var(--color-success) 17%, transparent), transparent 90%);
}
.bp-current-icon { display: grid; place-items: center; color: var(--color-success); }
.bp-current-text { display: grid; min-width: 0; gap: 1px; }
.bp-current-text strong,
.bp-current-text small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bp-current-text strong { font-size: 12px; font-weight: 700; }
.bp-current-text small {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 600;
}
/* Filter */
.bp-filter {
position: relative;
display: flex;
align-items: center;
color: var(--color-ink-faint);
}
.bp-filter :global(svg) { position: absolute; left: 8px; pointer-events: none; }
.bp-filter input {
height: 26px;
padding: 0 26px 0 26px;
border-color: var(--color-border-subtle);
border-radius: 6px;
font-size: 12px;
}
.bp-filter-clear {
position: absolute;
right: 3px;
display: grid;
place-items: center;
width: 20px;
min-width: 20px;
height: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-ink-faint);
}
.bp-filter-clear :global(svg) { position: static; }
.bp-filter-clear:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
/* List */
.bp-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0 2px 8px 6px;
scrollbar-gutter: stable;
}
/* Scrollbar look comes from .left-sidebar in app.css; start below the sticky group header. */
.bp-scroll::-webkit-scrollbar-track { margin-top: 30px; }
.bp-group + .bp-group { margin-top: 2px; }
.bp-group-head {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
margin: 0;
padding: 0 4px;
border: 0;
border-bottom: 1px solid var(--color-border-subtle);
border-radius: 0;
background: color-mix(in srgb, var(--group-accent) 7%, var(--color-surface));
box-shadow: inset 2px 0 color-mix(in srgb, var(--group-accent) 55%, transparent);
backdrop-filter: blur(12px);
color: var(--color-ink-faint);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-align: left;
text-transform: uppercase;
}
.bp-group-head.local { --group-accent: var(--color-info); }
.bp-group-head.remote { --group-accent: var(--color-accent); }
.bp-group-head { color: color-mix(in srgb, var(--group-accent) 65%, var(--color-ink-muted)); }
.bp-group-head:hover:not(:disabled) { color: var(--group-accent); background: color-mix(in srgb, var(--group-accent) 12%, var(--color-surface)); }
.bp-group-count {
color: var(--color-ink-faint);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.bp-empty {
padding: 8px 10px;
color: var(--color-ink-faint);
font-size: 12px;
}
.bp-row {
position: relative;
display: grid;
grid-template-columns: 12px 14px minmax(0, 1fr) auto auto;
align-items: center;
gap: 5px;
width: 100%;
min-height: 26px;
height: 26px;
margin: 1px 0 0;
padding: 0 4px 0 calc(4px + (var(--depth, 0) + 1) * var(--indent, 14px));
border: 0;
border-radius: 5px;
background: transparent;
color: var(--color-ink);
font-size: 12px;
font-weight: 550;
text-align: left;
cursor: default;
}
.bp-group-head + .bp-row { margin-top: 4px; }
/* Indent guides */
.bp-row::before {
content: "";
position: absolute;
top: -1px;
bottom: 0;
left: 10px;
width: calc((var(--depth, 0) + 1) * var(--indent, 14px));
background-image: linear-gradient(to right, var(--color-border) 1px, transparent 1px);
background-size: var(--indent, 14px) 100%;
background-repeat: repeat-x;
pointer-events: none;
}
.bp-row:hover:not(:disabled),
.bp-row.menu-open {
background: var(--color-surface-hover);
color: var(--color-ink);
}
.bp-chevron,
.bp-icon {
display: grid;
place-items: center;
color: var(--color-ink-faint);
}
.bp-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bp-name mark {
border-radius: 2px;
color: inherit;
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
}
/* Folder rows */
.bp-folder { cursor: pointer; }
.bp-folder .bp-name { color: var(--color-ink); font-weight: 600; }
.bp-folder:hover .bp-name { color: var(--color-ink); }
.bp-folder.remote-root .bp-icon { color: var(--color-accent); }
.bp-folder.remote-root .bp-name { color: var(--color-ink); font-weight: 700; }
.bp-count {
grid-column: 5;
min-width: 18px;
padding-right: 4px;
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 600;
text-align: right;
font-variant-numeric: tabular-nums;
}
.bp-current-dot {
grid-column: 4;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-success);
}
/* Branch rows */
.bp-meta {
display: inline-flex;
align-items: center;
gap: 5px;
}
.bp-track {
display: grid;
place-items: center;
color: var(--color-ink-faint);
opacity: 0.7;
}
.bp-track.local { opacity: 0.45; }
.bp-track.gone { color: var(--color-sync-ahead); opacity: 1; }
.bp-track.linked { color: var(--color-accent); opacity: 0.85; }
.bp-head-tag {
padding: 1px 5px;
border-radius: 4px;
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 14%, transparent);
font: 700 9px/1.3 var(--font-mono);
letter-spacing: 0.04em;
}
.bp-more {
display: grid;
place-items: center;
width: 20px;
min-width: 20px;
height: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-ink-faint);
opacity: 0;
}
.bp-branch:hover .bp-more,
.bp-branch.menu-open .bp-more,
.bp-more:focus-visible { opacity: 1; }
.bp-more:hover:not(:disabled) { color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 10%, transparent); }
.bp-branch.current {
color: var(--color-ink);
font-weight: 700;
background: color-mix(in srgb, var(--color-success) 9%, transparent);
box-shadow: inset 2px 0 0 var(--color-success);
}
.bp-branch.current .bp-icon { color: var(--color-success); }
.bp-branch.current:hover { background: color-mix(in srgb, var(--color-success) 14%, transparent); }
.bp-branch.flash { animation: bp-flash 900ms ease-out; }
@keyframes bp-flash {
0%, 30% { background: color-mix(in srgb, var(--color-success) 30%, transparent); }
100% { background: color-mix(in srgb, var(--color-success) 9%, transparent); }
}
</style>