104 lines
5.5 KiB
Svelte
104 lines
5.5 KiB
Svelte
<script lang="ts">
|
|
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
|
|
import type { AiCommitPlan } from "../types";
|
|
|
|
interface Props {
|
|
plan: AiCommitPlan;
|
|
isApplying: boolean;
|
|
onApply: (plan: AiCommitPlan) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let { plan, isApplying = false, onApply, onClose }: Props = $props();
|
|
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
|
|
$effect.pre(() => {
|
|
if (draft.groups.length === 0) draft = structuredClone(plan);
|
|
});
|
|
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
|
|
let validationIssue = $derived(
|
|
draft.groups.findIndex((group) => group.files.length === 0) >= 0
|
|
? "Every commit needs at least one file."
|
|
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
|
|
? "Every commit needs a message."
|
|
: "",
|
|
);
|
|
|
|
function setMessage(index: number, message: string) {
|
|
draft.groups[index].message = message;
|
|
}
|
|
|
|
function moveFile(file: string, from: number, to: number) {
|
|
if (from === to) return;
|
|
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
|
|
draft.groups[to].files = [...draft.groups[to].files, file];
|
|
}
|
|
|
|
function applyDraft() {
|
|
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
|
|
// throws a DataCloneError before the callback runs, which made the button
|
|
// appear to do nothing. A state snapshot is a plain, cloneable object.
|
|
onApply($state.snapshot(draft));
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
|
|
<header class="dialog-header">
|
|
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
|
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
|
|
</header>
|
|
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
|
|
<div class="split-groups">
|
|
{#each draft.groups as group, groupIndex}
|
|
<article class:empty={group.files.length === 0}>
|
|
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
|
|
<label>
|
|
<span>Commit message <em>AI-generated · editable</em></span>
|
|
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
|
|
</label>
|
|
{#if group.reason}<p>{group.reason}</p>{/if}
|
|
<div class="split-files">
|
|
{#each group.files as file}
|
|
<div><code>{file}</code>
|
|
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
|
|
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
|
|
</select>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
<footer class="dialog-footer">
|
|
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
|
|
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
|
|
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
|
|
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
|
|
</button></div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
|
|
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
|
.split-intro p{margin:0;line-height:1.5}
|
|
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
|
|
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
|
|
article.empty{border-color:#d88a45}
|
|
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
|
|
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
|
|
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
|
|
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
|
|
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
|
|
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
|
.split-files{display:grid;gap:5px}
|
|
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
|
|
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
|
|
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
|
|
.dialog-footer p.invalid{color:#e0a040}
|
|
</style>
|