Introduce a reusable SelectMenu component and replace native select controls across the UI. This centralizes select behavior and styling, enabling grouped options, placeholders, and a consistent popup interaction. Add comprehensive CSS and light-theme tweaks, and update rebase action styling to integrate the new control. - Add a unified SelectMenu component and wire change handlers. - Implement .select-menu styles, popup behavior, and theme overrides. - Replace ad-hoc native selects in dialogs and rebase UI for consistency.
144 lines
6.6 KiB
Svelte
144 lines
6.6 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";
|
|
import SelectMenu from "./SelectMenu.svelte";
|
|
|
|
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);
|
|
const rebaseActions: RebaseAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
|
|
|
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>
|
|
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: `${branch.remote ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
|
|
</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>
|
|
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={`Action for ${row.short_hash}`} onChange={(value) => updateAction(index, value as RebaseAction)} />
|
|
<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>
|