feat(confirm): centralize confirmation dialogs and add i18n

Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in
App.svelte so callers can await user responses instead of using window.confirm.
Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to
create dialog content for common cases. Many call sites were switched to use
requestConfirmation and now render the in-app ConfirmDialog; the previous
specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog)
were removed.

Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules,
and replace hardcoded English strings in several components (e.g. AiSettingsPage,
BlameDialog and many confirmation prompts) with translated keys.

Summary of effects:
- Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs.
- Centralizes confirmation UI and content construction in App.svelte.
- Adds i18n plumbing and updates UI text to use t().
- Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte.
This commit is contained in:
2026-09-17 21:41:35 +02:00
parent b00e3e5c18
commit 3e87c8f6a9
19 changed files with 1373 additions and 589 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import {GitMerge, AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import { t } from "../i18n.svelte";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit {
@@ -71,23 +72,23 @@
</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">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label={t("rebase.dialogLabel")} tabindex="-1">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
<span class="eyebrow">{t("rebase.eyebrow")}</span>
<h2 class="dialog-title">{t("rebase.dialogLabel")}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.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} />
<span>{t("rebase.rebaseOnto")} <strong>{currentBranch || t("rebase.currentBranch")}</strong> {t("rebase.onto")}</span>
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: branch.remote ? t("rebase.baseRemote", { name: branch.name }) : t("rebase.baseLocal", { name: branch.name }) }))} placeholder={t("rebase.selectBase")} disabled={isBusy || isLoading} onChange={onBaseChange} />
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
<p>{t("rebase.hint")}</p>
</section>
{#if error}
@@ -95,24 +96,24 @@
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("rebase.loading")}</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
<div class="blank-state">{t("rebase.selectBaseHint")}</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
<div class="blank-state">{t("rebase.noCommits")}</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
<div class="rebase-plan" role="list" aria-label={t("rebase.planLabel")}>
{#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>
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title={t("rebase.moveUp")}><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title={t("rebase.moveDown")}><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)} />
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={t("rebase.actionFor", { hash: 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" />
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={t("rebase.newMessageFor", { hash: row.short_hash })} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
@@ -124,19 +125,19 @@
{/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>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidSquash")}</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidReword")}</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<span class="dialog-footer-info">{t("rebase.keptCount", { kept: keptCount, total: rows.length })}</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{t("common.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
{t("rebase.start")}
</button>
</div>
</footer>