Files
GitLite/src/lib/components/BranchPanel.svelte
T
Christoph 3e87c8f6a9 feat(confirm): centralize confirmation dialogs and add i18n
Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in
App.svelte so callers can await user responses instead of using window.confirm.
Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to
create dialog content for common cases. Many call sites were switched to use
requestConfirmation and now render the in-app ConfirmDialog; the previous
specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog)
were removed.

Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules,
and replace hardcoded English strings in several components (e.g. AiSettingsPage,
BlameDialog and many confirmation prompts) with translated keys.

Summary of effects:
- Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs.
- Centralizes confirmation UI and content construction in App.svelte.
- Adds i18n plumbing and updates UI text to use t().
- Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte.
2026-09-17 21:41:35 +02:00

1102 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"
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"
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, #4eca76 26%, transparent);
border-radius: 7px;
background: linear-gradient(90deg, color-mix(in srgb, #4eca76 11%, transparent), transparent 85%);
color: var(--color-ink);
text-align: left;
}
.bp-current:hover:not(:disabled) {
border-color: color-mix(in srgb, #4eca76 42%, transparent);
background: linear-gradient(90deg, color-mix(in srgb, #4eca76 17%, transparent), transparent 90%);
}
.bp-current-icon { display: grid; place-items: center; color: #4eca76; }
.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: var(--color-surface);
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:hover:not(:disabled) { color: var(--color-ink-muted); background: 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: #4eca76;
}
/* 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: #4eca76;
background: color-mix(in srgb, #4eca76 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, #4eca76 9%, transparent);
box-shadow: inset 2px 0 0 #4eca76;
}
.bp-branch.current .bp-icon { color: #4eca76; }
.bp-branch.current:hover { background: color-mix(in srgb, #4eca76 14%, transparent); }
.bp-branch.flash { animation: bp-flash 900ms ease-out; }
@keyframes bp-flash {
0%, 30% { background: color-mix(in srgb, #4eca76 30%, transparent); }
100% { background: color-mix(in srgb, #4eca76 9%, transparent); }
}
</style>