This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface. - Implements full planning and execution flow for interactive rebase - Adds functionality to list and restore commits using the reflog history - Updates Rust backend commands to support advanced git operations
149 lines
6.8 KiB
Svelte
149 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
|
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
|
|
|
interface PlanRow extends RebaseCommit {
|
|
action: RebaseAction;
|
|
message: string;
|
|
}
|
|
|
|
interface Props {
|
|
branches: GitBranchInfo[];
|
|
currentBranch: string;
|
|
base: string;
|
|
commits: RebaseCommit[];
|
|
isLoading: boolean;
|
|
isBusy: boolean;
|
|
operation: string;
|
|
error: string;
|
|
onBaseChange: (base: string) => void;
|
|
onStart: (plan: RebasePlanItem[]) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let {
|
|
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
|
|
isBusy = false, operation = "", error = "", onBaseChange = () => {},
|
|
onStart = () => {}, onClose = () => {},
|
|
}: Props = $props();
|
|
|
|
let rows = $state<PlanRow[]>([]);
|
|
|
|
$effect(() => {
|
|
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
|
|
});
|
|
|
|
let availableBases = $derived(branches.filter((branch) => !branch.current));
|
|
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
|
|
let invalidSquash = $derived(rows.some((row, index) =>
|
|
(row.action === "squash" || row.action === "fixup")
|
|
&& rows.slice(0, index).every((previous) => previous.action === "drop")
|
|
));
|
|
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
|
|
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
|
|
|
|
function updateAction(index: number, action: RebaseAction) {
|
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
|
|
}
|
|
|
|
function updateMessage(index: number, message: string) {
|
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
|
|
}
|
|
|
|
function move(index: number, direction: -1 | 1) {
|
|
const target = index + direction;
|
|
if (target < 0 || target >= rows.length) return;
|
|
const next = [...rows];
|
|
[next[index], next[target]] = [next[target], next[index]];
|
|
rows = next;
|
|
}
|
|
|
|
function start() {
|
|
if (!canStart) return;
|
|
onStart(rows.map((row) => ({
|
|
hash: row.hash,
|
|
action: row.action,
|
|
message: row.action === "reword" ? row.message.trim() : null,
|
|
})));
|
|
}
|
|
</script>
|
|
|
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
|
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">Rewrite local history</span>
|
|
<h2 class="dialog-title">Interactive rebase</h2>
|
|
</div>
|
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
|
</header>
|
|
|
|
<div class="interactive-rebase-body">
|
|
<section class="rebase-base-bar">
|
|
<label>
|
|
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
|
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
|
|
<option value="" disabled>Select a base branch</option>
|
|
{#each availableBases as branch (branch.name)}
|
|
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
|
</section>
|
|
|
|
{#if error}
|
|
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
|
|
{/if}
|
|
|
|
{#if isLoading}
|
|
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
|
|
{:else if !base}
|
|
<div class="blank-state">Select the branch or commit that should become the new base.</div>
|
|
{:else if rows.length === 0}
|
|
<div class="blank-state">No linear commits are available above this base.</div>
|
|
{:else}
|
|
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
|
|
{#each rows as row, index (row.hash)}
|
|
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
|
|
<div class="rebase-order-actions">
|
|
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
|
|
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
|
|
</div>
|
|
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
|
|
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
|
|
</select>
|
|
<code>{row.short_hash}</code>
|
|
<div class="rebase-commit-copy">
|
|
{#if row.action === "reword"}
|
|
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
|
|
{:else}
|
|
<strong>{row.summary}</strong>
|
|
{/if}
|
|
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if invalidSquash}
|
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
|
|
{:else if invalidReword}
|
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<footer class="dialog-footer">
|
|
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
|
|
<div class="rebase-footer-actions">
|
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
|
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
|
|
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
|
|
Start rebase
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|