feat(git): add tag management and cherry-pick workflow

This update extends the Git backend and UI to support listing,
creating, deleting, and pushing tags. It also adds cherry-pick
commands with proper in-progress detection and conflict handling, and
prevents other operations while a cherry-pick is active.

- Add GitTag model, tag listing, and tag CRUD/push commands
- Implement cherry-pick start/continue/abort with status tracking
- Update UI to display tags and gate actions during cherry-pick
This commit is contained in:
Christoph Brandau
2026-07-06 15:05:12 +02:00
parent 3e2885f64d
commit 0bad722c7a
9 changed files with 673 additions and 19 deletions
+183 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -45,6 +45,7 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
@@ -53,12 +54,16 @@
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
@@ -67,17 +72,28 @@
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
}: 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 panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -270,12 +286,74 @@
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;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) 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) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
}
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") closeBranchContextMenu();
if (event.key === "Escape") closeAllContextMenus();
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
@@ -300,7 +378,7 @@
{#if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0}
{:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
@@ -458,6 +536,86 @@
{/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>
{/if}
@@ -499,4 +657,24 @@
</button>
</div>
{/if}
{#if contextTag}
<div
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>