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.
816 lines
27 KiB
Svelte
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>
|