add Repo Managment / Kontext Menu in Braches
This commit is contained in:
@@ -1,7 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
|
||||
interface BranchFolderNode {
|
||||
kind: "folder";
|
||||
id: string;
|
||||
name: string;
|
||||
children: BranchTreeNode[];
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
interface BranchLeafRow {
|
||||
kind: "branch";
|
||||
id: string;
|
||||
branch: GitBranchInfo;
|
||||
displayName: string;
|
||||
depth: number;
|
||||
scopeLabel: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
branches: GitBranchInfo[];
|
||||
localBranches: GitBranchInfo[];
|
||||
@@ -11,6 +50,8 @@
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,6 +63,8 @@
|
||||
onCheckout = () => {},
|
||||
onMerge = () => {},
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let localOpen = $state(true);
|
||||
@@ -29,6 +72,118 @@
|
||||
let createOpen = $state(false);
|
||||
let newBranchName = $state("");
|
||||
let createInput = $state<HTMLInputElement | null>(null);
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $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,
|
||||
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;
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -57,9 +212,49 @@
|
||||
if (target?.closest("button")) return;
|
||||
onCheckout(branch);
|
||||
}
|
||||
|
||||
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isBusy || branch.remote) return;
|
||||
|
||||
const rect = panelElement?.getBoundingClientRect();
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||
}
|
||||
|
||||
function closeBranchContextMenu() {
|
||||
contextBranch = null;
|
||||
}
|
||||
|
||||
async function renameContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeBranchContextMenu();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
|
||||
|
||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Branches</span>
|
||||
@@ -127,34 +322,58 @@
|
||||
{#if localBranches.length === 0}
|
||||
<div class="branch-empty">No local branches.</div>
|
||||
{:else}
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={branch.current}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>local</span>
|
||||
{#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)}
|
||||
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>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -180,38 +399,87 @@
|
||||
{#if remoteBranches.length === 0}
|
||||
<div class="branch-empty">No remote branches.</div>
|
||||
{:else}
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={branch.current}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>remote</span>
|
||||
{#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)}
|
||||
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)}
|
||||
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>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextBranch}
|
||||
<div
|
||||
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={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextBranch}
|
||||
disabled={isBusy || contextBranch.current}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
interface Props {
|
||||
branch: GitBranchInfo;
|
||||
isBusy: boolean;
|
||||
onRename: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
branch,
|
||||
isBusy = false,
|
||||
onRename = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user