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.
1106 lines
36 KiB
Svelte
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>
|