feat(git): rename remote branches atomically via push
Adds a new command to rename remote branches and update tracking refs. Renaming remote refs is performed atomically using a single push. The push creates the new remote ref and deletes the old if it succeeds. The frontend now invokes remote-rename when needed and shows labels. - Atomic remote rename via push with create/ref and delete - Frontend supports remote branch renames from the branch panel - Compare UI now shows labels for remote refs in results
This commit is contained in:
+86
-21
@@ -100,6 +100,7 @@
|
||||
setCommitNote,
|
||||
updateRemote,
|
||||
renameBranch,
|
||||
renameRemoteBranch,
|
||||
rebaseAbort,
|
||||
rebaseBranch,
|
||||
rebaseContinue,
|
||||
@@ -359,6 +360,8 @@
|
||||
let compareFrom = "";
|
||||
let compareTo = "";
|
||||
let comparison: GitCommitComparison | null = null;
|
||||
let comparisonFromLabel = "";
|
||||
let comparisonToLabel = "";
|
||||
let newBranchCommit: GitCommit | null = null;
|
||||
let renameBranchTarget: GitBranchInfo | null = null;
|
||||
let deleteBranchTarget: GitBranchInfo | null = null;
|
||||
@@ -1852,6 +1855,8 @@
|
||||
compareFrom = "";
|
||||
compareTo = "";
|
||||
comparison = null;
|
||||
comparisonFromLabel = "";
|
||||
comparisonToLabel = "";
|
||||
compareSelectOpen = false;
|
||||
compareDialogOpen = false;
|
||||
interactiveRebaseOpen = false;
|
||||
@@ -1981,15 +1986,12 @@
|
||||
commitHistoryLoadingMore = false;
|
||||
commitHistoryLoadError = "";
|
||||
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
||||
const hashes = new Set(commits.map((c) => c.hash));
|
||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) {
|
||||
comparison = null;
|
||||
compareDialogOpen = false;
|
||||
selectedDiffPath = "";
|
||||
pendingRestoreFile = null;
|
||||
}
|
||||
const targets = new Set([
|
||||
...commits.map((commit) => commit.hash),
|
||||
...branches.map(compareRefForBranch),
|
||||
]);
|
||||
if (compareFrom && !targets.has(compareFrom)) compareFrom = "";
|
||||
if (compareTo && !targets.has(compareTo)) compareTo = "";
|
||||
}
|
||||
|
||||
async function loadMoreCommitHistory() {
|
||||
@@ -2482,22 +2484,47 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renameLocalBranch(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.remote) return;
|
||||
function openRenameBranchDialog(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath) return;
|
||||
if (branch.remote && branch.name.indexOf("/") < 1) {
|
||||
errorMessage = "Could not determine remote name.";
|
||||
return;
|
||||
}
|
||||
renameBranchTarget = branch;
|
||||
trackEvent("branch_rename_dialog_opened");
|
||||
trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 });
|
||||
}
|
||||
|
||||
async function submitRenameBranch(branchName: string) {
|
||||
const branch = renameBranchTarget;
|
||||
const name = branchName.trim();
|
||||
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
|
||||
if (!activeRepoPath || !branch || !name) return;
|
||||
|
||||
if (branch.remote) {
|
||||
const slash = branch.name.indexOf("/");
|
||||
if (slash < 1) {
|
||||
errorMessage = "Could not determine remote name.";
|
||||
return;
|
||||
}
|
||||
const remote = branch.name.slice(0, slash);
|
||||
const oldRemoteBranch = branch.name.slice(slash + 1);
|
||||
if (name === oldRemoteBranch) return;
|
||||
|
||||
await runOperation(`Renaming ${branch.name} on remote`, async () => {
|
||||
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name));
|
||||
renameBranchTarget = null;
|
||||
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||
trackEvent("branch_renamed", { remote: 1 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === branch.name) return;
|
||||
|
||||
await runOperation(`Renaming ${branch.name}`, async () => {
|
||||
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
|
||||
renameBranchTarget = null;
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_renamed");
|
||||
trackEvent("branch_renamed", { remote: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2841,6 +2868,8 @@
|
||||
await runOperation("Previewing reflog entry", async () => {
|
||||
const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
|
||||
comparison = result;
|
||||
comparisonFromLabel = entry.selector;
|
||||
comparisonToLabel = "HEAD";
|
||||
selectedDiffPath = result.files[0]?.path ?? "";
|
||||
diffHighlightQuery = "";
|
||||
pendingRestoreFile = null;
|
||||
@@ -3816,6 +3845,8 @@
|
||||
diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path,
|
||||
);
|
||||
comparison = result;
|
||||
comparisonFromLabel = "";
|
||||
comparisonToLabel = "";
|
||||
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
|
||||
pendingRestoreFile = { commit: target, file };
|
||||
compareDialogOpen = true;
|
||||
@@ -4072,12 +4103,36 @@
|
||||
|
||||
// ── Compare ────────────────────────────────────────────────────────────────
|
||||
|
||||
function compareRefForBranch(branch: GitBranchInfo): string {
|
||||
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||
}
|
||||
|
||||
function compareLabelForTarget(target: string): string {
|
||||
const branch = branches.find((candidate) => compareRefForBranch(candidate) === target);
|
||||
if (branch) return branch.name;
|
||||
return commits.find((commit) => commit.hash === target)?.short_hash ?? "";
|
||||
}
|
||||
|
||||
function openCompareSelect() {
|
||||
if (!hasRepository) return;
|
||||
if (!compareFrom) {
|
||||
const current = branches.find((branch) => branch.current);
|
||||
if (current) compareFrom = compareRefForBranch(current);
|
||||
}
|
||||
compareSelectOpen = true;
|
||||
trackEvent("compare_opened");
|
||||
}
|
||||
|
||||
function compareBranchWithCurrent(branch: GitBranchInfo) {
|
||||
if (!hasRepository) return;
|
||||
const selected = compareRefForBranch(branch);
|
||||
const current = branches.find((candidate) => candidate.current);
|
||||
compareFrom = current ? compareRefForBranch(current) : "";
|
||||
compareTo = selected === compareFrom ? "" : selected;
|
||||
compareSelectOpen = true;
|
||||
trackEvent("compare_opened", { source: "branch_context", remote: branch.remote ? 1 : 0 });
|
||||
}
|
||||
|
||||
function openGlobalSearchDialog() {
|
||||
globalSearchOpen = true;
|
||||
trackEvent("global_search_opened");
|
||||
@@ -4096,11 +4151,13 @@
|
||||
aiSettingsOpen = true;
|
||||
}
|
||||
|
||||
async function compareSelectedCommits() {
|
||||
async function compareSelectedTargets() {
|
||||
if (!canCompare) return;
|
||||
await runOperation("Comparing commits", async () => {
|
||||
await runOperation("Comparing revisions", async () => {
|
||||
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
||||
comparison = result;
|
||||
comparisonFromLabel = compareLabelForTarget(compareFrom);
|
||||
comparisonToLabel = compareLabelForTarget(compareTo);
|
||||
selectedDiffPath = result.files[0]?.path ?? "";
|
||||
diffHighlightQuery = "";
|
||||
pendingRestoreFile = null;
|
||||
@@ -4117,6 +4174,8 @@
|
||||
await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
|
||||
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
|
||||
comparison = result;
|
||||
comparisonFromLabel = "";
|
||||
comparisonToLabel = "";
|
||||
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
|
||||
diffHighlightQuery = "";
|
||||
pendingRestoreFile = null;
|
||||
@@ -4133,6 +4192,8 @@
|
||||
await runOperation(`Diffing ${hit.file}`, async () => {
|
||||
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
|
||||
comparison = result;
|
||||
comparisonFromLabel = "";
|
||||
comparisonToLabel = "";
|
||||
selectedDiffPath = result.files[0]?.path ?? hit.file;
|
||||
diffHighlightQuery = lastSearchQuery;
|
||||
pendingRestoreFile = null;
|
||||
@@ -4711,10 +4772,11 @@
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
onCheckout={checkout}
|
||||
onCompareBranch={compareBranchWithCurrent}
|
||||
onMerge={merge}
|
||||
onRebase={rebaseOnto}
|
||||
onCreateBranch={createNewBranch}
|
||||
onRenameBranch={renameLocalBranch}
|
||||
onRenameBranch={openRenameBranchDialog}
|
||||
onDeleteBranch={deleteLocalBranch}
|
||||
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
|
||||
onCreateTag={createNewTag}
|
||||
@@ -5195,7 +5257,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Rename a local branch from the branch context menu -->
|
||||
<!-- Rename a local or remote branch from the branch context menu -->
|
||||
{#if renameBranchTarget}
|
||||
<RenameBranchDialog
|
||||
branch={renameBranchTarget}
|
||||
@@ -5228,7 +5290,7 @@
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Compare: pick the two commits to diff -->
|
||||
<!-- Interactive rebase -->
|
||||
{#if interactiveRebaseOpen}
|
||||
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
|
||||
<module.default
|
||||
@@ -5261,10 +5323,11 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Compare: pick the two commits to diff -->
|
||||
<!-- Compare: pick two branches or commits to diff -->
|
||||
{#if compareSelectOpen}
|
||||
<CompareSelectDialog
|
||||
{commits}
|
||||
{branches}
|
||||
{compareFrom}
|
||||
{compareTo}
|
||||
{canCompare}
|
||||
@@ -5272,7 +5335,7 @@
|
||||
{operation}
|
||||
onCompareFromChange={(val) => { compareFrom = val; }}
|
||||
onCompareToChange={(val) => { compareTo = val; }}
|
||||
onCompare={compareSelectedCommits}
|
||||
onCompare={compareSelectedTargets}
|
||||
onClose={() => { compareSelectOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -5284,6 +5347,8 @@
|
||||
{comparison}
|
||||
{selectedDiffPath}
|
||||
{isBusy}
|
||||
fromLabel={comparisonFromLabel}
|
||||
toLabel={comparisonToLabel}
|
||||
highlightQuery={diffHighlightQuery}
|
||||
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
||||
onClose={closeCompareDialog}
|
||||
|
||||
+45
-1
@@ -3023,6 +3023,16 @@
|
||||
.compare-field { display: grid; gap: 4px; min-width: 0; }
|
||||
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
|
||||
.compare-target-help {
|
||||
margin: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
|
||||
border-radius: 8px;
|
||||
color: var(--color-ink-dim);
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface-raised));
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
|
||||
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
|
||||
@@ -3685,6 +3695,34 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.new-branch-field > div { display: flex; min-width: 0; }
|
||||
.new-branch-field > div > input { width: 100%; min-width: 0; }
|
||||
.remote-branch-name-field > strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
padding: 0 0 0 11px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-right: 0;
|
||||
border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);
|
||||
color: var(--color-ink-faint);
|
||||
background: var(--color-surface-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.remote-branch-name-field > input { border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }
|
||||
.rename-remote-note {
|
||||
margin: -2px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--color-ink-dim);
|
||||
background: var(--color-surface-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rename-remote-note strong { color: var(--color-ink); font-family: var(--font-mono); font-weight: 700; }
|
||||
.new-branch-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -3703,7 +3741,8 @@
|
||||
.compare-restore { max-width: 170px; min-width: 0; }
|
||||
.compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
|
||||
.dialog-range { display: flex; align-items: center; gap: 8px; max-width: min(68vw, 780px); margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
|
||||
.dialog-range .hash { min-width: 0; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; }
|
||||
|
||||
.dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; }
|
||||
@@ -3852,6 +3891,11 @@
|
||||
}
|
||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||
.split-col-hash {
|
||||
min-width: 0;
|
||||
max-width: min(42%, 260px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgba(90,140,248,0.12);
|
||||
|
||||
@@ -151,8 +151,8 @@
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -49,6 +49,7 @@
|
||||
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>;
|
||||
@@ -72,6 +73,7 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onCompareBranch = () => {},
|
||||
onMerge = () => {},
|
||||
onRebase = () => {},
|
||||
onCreateBranch = () => {},
|
||||
@@ -250,7 +252,7 @@
|
||||
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) - 190);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
@@ -268,6 +270,13 @@
|
||||
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;
|
||||
@@ -677,6 +686,10 @@
|
||||
<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
|
||||
@@ -690,9 +703,9 @@
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
|
||||
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
|
||||
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
|
||||
action("compare", "compare", isGerman ? "Commits vergleichen" : "Compare commits", isGerman ? "Unterschiede zwischen zwei Revisionen" : "Diff two revisions", onOpenCompare),
|
||||
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
fromLabel?: string;
|
||||
toLabel?: string;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
@@ -32,6 +34,8 @@
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
fromLabel = "",
|
||||
toLabel = "",
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
onClose = () => {},
|
||||
@@ -219,15 +223,15 @@
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
@@ -289,11 +293,11 @@
|
||||
<div class="split-col-headers">
|
||||
<div class="split-col-label">
|
||||
<span>Before</span>
|
||||
<span class="split-col-hash">{comparison.from_short}</span>
|
||||
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
</div>
|
||||
<div class="split-col-label">
|
||||
<span>After</span>
|
||||
<span class="split-col-hash">{comparison.to_short}</span>
|
||||
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
import type { GitBranch, GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
branches: GitBranch[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
branches = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
@@ -32,6 +34,14 @@
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function branchValue(branch: GitBranch): string {
|
||||
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||
}
|
||||
|
||||
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||
let targetCount = $derived(branches.length + commits.length);
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
@@ -42,53 +52,89 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{#if targetCount < 2}
|
||||
<div class="blank-state">At least two branches or commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<span>Base</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<span>Compare with</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
{#if operation === "Comparing revisions"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
@@ -98,9 +144,11 @@
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
<div class="blank-state">Select two different branches or commits to compare.</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
<div class="compare-target-help">
|
||||
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,29 @@
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function remoteName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return "";
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||
}
|
||||
|
||||
function editableName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return branch.name;
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||
}
|
||||
|
||||
let originalName = $derived(editableName(branch));
|
||||
let remote = $derived(remoteName(branch));
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
name = originalName;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
if (!value || value === originalName) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +49,10 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "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">
|
||||
@@ -48,27 +63,37 @@
|
||||
<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
|
||||
/>
|
||||
<div class:remote-branch-name-field={branch.remote}>
|
||||
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if branch.remote}
|
||||
<p class="rename-remote-note">
|
||||
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<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}>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
{branch.remote ? "Rename on remote" : "Rename"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -133,6 +133,15 @@ export function renameBranch(
|
||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function renameRemoteBranch(
|
||||
path: string,
|
||||
remote: string,
|
||||
oldBranch: string,
|
||||
newBranch: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user