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:
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -40,6 +40,7 @@
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -54,6 +55,7 @@
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
@@ -380,6 +382,13 @@
|
||||
await onRestoreCommit(commit);
|
||||
}
|
||||
|
||||
async function cherryPickContextCommit() {
|
||||
const commit = contextCommit;
|
||||
if (!commit || isBusy) return;
|
||||
closeCommitContextMenu();
|
||||
await onCherryPickCommit(commit);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
@@ -656,6 +665,16 @@
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={cherryPickContextCommit}
|
||||
disabled={isBusy}
|
||||
title="Apply this commit's changes on top of the current branch"
|
||||
>
|
||||
<Cherry size={14} aria-hidden="true" />
|
||||
Cherry-pick
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
GitTag,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
RepositoryBundle,
|
||||
@@ -94,6 +95,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
|
||||
export function listTags(path: string): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("list_tags", { path });
|
||||
}
|
||||
|
||||
export function createTag(
|
||||
path: string,
|
||||
name: string,
|
||||
target?: string,
|
||||
message?: string,
|
||||
): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("create_tag", {
|
||||
path,
|
||||
name,
|
||||
target: target?.trim() ? target.trim() : null,
|
||||
message: message?.trim() ? message.trim() : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("delete_tag", { path, name });
|
||||
}
|
||||
|
||||
export function pushTag(
|
||||
path: string,
|
||||
name: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
): Promise<void> {
|
||||
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
|
||||
}
|
||||
|
||||
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
|
||||
}
|
||||
|
||||
export function cherryPickContinue(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_continue", { path });
|
||||
}
|
||||
|
||||
export function cherryPickAbort(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_abort", { path });
|
||||
}
|
||||
|
||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stage_files", { path, files });
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface GitStatus {
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
rebase_in_progress: boolean;
|
||||
cherry_pick_in_progress: boolean;
|
||||
}
|
||||
|
||||
export interface GitFileStatus {
|
||||
@@ -59,6 +60,15 @@ export interface GitBranch {
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
export interface GitTag {
|
||||
name: string;
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string | null;
|
||||
date: string;
|
||||
annotated: boolean;
|
||||
}
|
||||
|
||||
export interface GitStash {
|
||||
selector: string;
|
||||
index: number;
|
||||
@@ -95,6 +105,7 @@ export interface GitRepositoryFile {
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
tags: GitTag[];
|
||||
stashes: GitStash[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
|
||||
Reference in New Issue
Block a user