Files
GitLite/src/lib/components/BranchPanel.svelte
T
Christoph 0dac3e9f09 feat(remote): support authenticated deletion of remote branches
Add support for authenticated deletion of remote branches and folders.
The backend adds a batch delete command that queries the remote,
performs an atomic push --delete when possible with a fallback, and
prunes stale tracking refs; it accepts optional credentials. The
frontend and JS API integrate credential handling and prompt users when
authentication is required.

- New batch deletion command with optional username/password for auth.
- Uses ls-remote to scope deletions, tries --atomic then falls back.
- Frontend updates: credential dialog/action, pendingRemoteDelete state.
2026-08-15 19:38:39 +02:00

816 lines
27 KiB
Svelte

<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
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[];
}
interface BranchLeafRow {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
depth: number;
scopeLabel: string;
}
interface Props {
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
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>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
onManageWorktrees: () => void;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
onCompareBranch = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | 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);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
function createFolder(id: string, name: string): BranchFolderNode {
return {
kind: "folder",
id,
name,
children: [],
branchCount: 0,
current: false,
branches: [],
folders: new Map(),
};
}
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): 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];
const folderPath = folderParts.slice(0, index + 1).join("/");
let folder = parent.folders.get(folderName);
if (!folder) {
folder = createFolder(`${scope}:folder:${folderPath}`, 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: `${scope}:branch:${branch.name}`,
branch,
displayName,
});
}
sortBranchNodes(root.children);
const rows: BranchRow[] = [];
flattenBranchNodes(root.children, rows, 0, scopeLabel);
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, scopeLabel: string) {
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,
});
if (isBranchFolderOpen(node.id)) {
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
}
} else {
rows.push({
kind: "branch",
id: node.id,
branch: node.branch,
displayName: node.displayName,
depth,
scopeLabel,
});
}
}
}
function isBranchFolderOpen(id: string) {
return !collapsedBranchFolders.has(id);
}
function toggleBranchFolder(id: string) {
const next = new Set(collapsedBranchFolders);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedBranchFolders = next;
}
function openCreateForm() {
if (!hasRepository || isBusy) return;
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 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 openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
contextBranch = branch;
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
event.preventDefault();
event.stopPropagation();
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
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 openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
contextTag = tag;
tagContextMenuX = event.clientX + 2;
tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() {
closeBranchContextMenu();
closeTagContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeAllContextMenus();
}
</script>
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head">
<div>
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
>
<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 ? "Expand branches" : "Collapse branches"}
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
>
{#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">Open a repository to list branches.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if createOpen}
<form class="branch-create-form" onsubmit={submitCreate}>
<GitBranch size={15} aria-hidden="true" />
<input
bind:this={createInput}
bind:value={newBranchName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="new-branch-name"
aria-label="New branch name"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { localOpen = !localOpen; }}
aria-expanded={localOpen}
>
{#if localOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</button>
{#if localOpen}
{#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div>
{:else}
{#each localBranchRows as row (row.id)}
{#if row.kind === "folder"}
<button
class="branch-folder-row"
class:current={row.current}
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
{#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{/if}
</article>
{/if}
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { remoteOpen = !remoteOpen; }}
aria-expanded={remoteOpen}
>
{#if remoteOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span>
</button>
{#if remoteOpen}
{#if remoteBranches.length === 0}
<div class="branch-empty">No remote branches.</div>
{:else}
{#each remoteBranchRows as row (row.id)}
{#if row.kind === "folder"}
<button
class="branch-folder-row"
class:current={row.current}
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
{#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{/if}
</article>
{/if}
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle worktree-group-toggle"
type="button"
onclick={onManageWorktrees}
disabled={isBusy}
aria-haspopup="dialog"
title="Manage repository worktrees"
>
<HardDrive size={14} aria-hidden="true" />
<span>Worktrees</span>
<span class="branch-group-count">Manage</span>
</button>
</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={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Compare with...
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
</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 ? "Rename remote..." : "Rename"}
</button>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
{contextBranch.remote ? "Delete remote" : "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={`Actions for branch folder ${contextFolder.name}`}
>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
>
<Trash2 size={14} aria-hidden="true" />
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
</button>
</div>
{/if}
{#if contextTag}
<div
bind:this={tagContextMenuElement}
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>