add Repo Managment / Kontext Menu in Braches

This commit is contained in:
Christoph Brandau
2026-07-02 08:35:29 +02:00
parent 97fc4fc1e0
commit eef4869bbb
7 changed files with 719 additions and 62 deletions
+61 -1
View File
@@ -16,6 +16,7 @@
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
@@ -28,6 +29,7 @@
cancelCodeSearch,
applyFilePatch,
createBranch,
deleteBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
getStatus,
@@ -40,6 +42,7 @@
openRepositoryBundle,
pull,
push,
renameBranch,
getRemoteUrl,
credLoad,
credSave,
@@ -121,6 +124,7 @@
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
@@ -662,6 +666,45 @@
});
}
function renameLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
renameBranchTarget = branch;
}
async function submitRenameBranch(branchName: string) {
const branch = renameBranchTarget;
const name = branchName.trim();
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function deleteLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
if (branch.current) {
errorMessage = "The current branch cannot be deleted.";
return;
}
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
if (!confirmed) return;
await runOperation(`Deleting ${branch.name}`, async () => {
applyStatus(await deleteBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
@@ -1271,16 +1314,21 @@
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
function handleWindowContextMenu(event: MouseEvent) {
event.preventDefault();
}
</script>
<svelte:head>
<title>GitLite</title>
</svelte:head>
<svelte:window on:keydown={handleWindowKeydown} />
<svelte:window on:keydown={handleWindowKeydown} on:contextmenu={handleWindowContextMenu} />
<main class="shell">
<TitleBar
@@ -1509,6 +1557,8 @@
onCheckout={checkout}
onMerge={merge}
onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
/>
<ExplorerPanel
{repoFiles}
@@ -1658,6 +1708,16 @@
/>
{/if}
<!-- Rename a local branch from the branch context menu -->
{#if renameBranchTarget}
<RenameBranchDialog
branch={renameBranchTarget}
{isBusy}
onRename={submitRenameBranch}
onClose={() => { renameBranchTarget = null; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
+121
View File
@@ -986,6 +986,8 @@
align-items: center;
gap: 6px;
}
.branch-panel { position: relative; }
.branch-create-toggle {
width: 26px;
min-width: 26px;
@@ -1092,6 +1094,60 @@
font-size: 12px;
}
.branch-folder-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
justify-content: stretch;
gap: 7px;
width: 100%;
min-height: 34px;
padding: 5px 8px;
padding-left: calc(8px + var(--branch-indent, 0px));
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--color-ink-dim);
text-align: left;
}
.branch-folder-row + .branch-folder-row,
.branch-folder-row + .branch-row,
.branch-row + .branch-folder-row {
margin-top: 3px;
}
.branch-folder-row:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: var(--color-surface-hover);
color: var(--color-ink);
}
.branch-folder-row.current {
border-color: rgba(78,202,118,0.2);
background: rgba(78,202,118,0.08);
}
.branch-folder-row svg { color: var(--color-ink-faint); }
.branch-folder-row.current svg { color: #4eca76; }
.branch-folder-name {
overflow: hidden;
color: var(--color-ink-muted);
font-size: 12.5px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.branch-folder-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
min-height: 18px;
padding: 0 6px;
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(94,110,156,0.14);
font-size: 10px;
font-weight: 800;
}
.branch-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1099,6 +1155,7 @@
gap: 8px;
min-height: 46px;
padding: 7px 8px;
padding-left: calc(8px + var(--branch-indent, 0px));
border: 1px solid transparent;
border-radius: 8px;
transition: background 120ms, border-color 120ms;
@@ -1118,6 +1175,57 @@
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
.branch-context-menu {
position: absolute;
z-index: 120;
display: grid;
gap: 2px;
min-width: 184px;
padding: 5px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
}
.branch-context-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
min-height: 30px;
padding: 6px 8px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: var(--color-ink-muted);
font-size: 12px;
font-weight: 700;
text-align: left;
}
.branch-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
}
.branch-context-menu button.danger {
color: #ff9aa8;
}
.branch-context-menu button.danger:hover:not(:disabled) {
border-color: rgba(255,92,117,0.34);
background: rgba(255,92,117,0.12);
color: #ffd0d6;
}
.branch-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
/* --- Explorer --- */
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
@@ -1378,12 +1486,25 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.rename-branch-dialog {
display: block;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.rename-branch-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.new-branch-target {
display: flex;
align-items: center;
+324 -56
View File
@@ -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>
+12
View File
@@ -45,6 +45,18 @@ export function createBranch(
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
}
export function renameBranch(
path: string,
oldBranch: string,
newBranch: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files });
}