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
+50 -49
View File
@@ -24,6 +24,7 @@
} from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { t } from "../i18n.svelte";
type Scope = "local" | "remote";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -307,12 +308,12 @@
function branchTitle(branch: GitBranchInfo) {
const tracking = trackingFor(branch);
const lines = [branch.name];
if (branch.current) lines.push("Current branch (HEAD)");
if (tracking.kind === "tracked") lines.push(`Tracks ${tracking.upstream}`);
if (tracking.kind === "gone") lines.push(`Upstream ${tracking.upstream} is gone`);
if (tracking.kind === "local") lines.push("Local only not published");
if (tracking.kind === "remote-tracked") lines.push(`Checked out locally as ${tracking.local}`);
if (!branch.current) lines.push("Double-click to checkout");
if (branch.current) lines.push(t("branches.tipCurrent"));
if (tracking.kind === "tracked") lines.push(t("branches.tipTracks", { upstream: tracking.upstream }));
if (tracking.kind === "gone") lines.push(t("branches.tipGone", { upstream: tracking.upstream }));
if (tracking.kind === "local") lines.push(t("branches.tipLocalOnly"));
if (tracking.kind === "remote-tracked") lines.push(t("branches.tipCheckedOut", { name: tracking.local }));
if (!branch.current) lines.push(t("branches.tipDoubleClick"));
return lines.join("\n");
}
@@ -519,7 +520,7 @@
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={open}
title={`${row.name} · ${row.branchCount} ${row.branchCount === 1 ? "branch" : "branches"}`}
title={row.branchCount === 1 ? t("branches.folderTitleOne", { name: row.name }) : t("branches.folderTitle", { name: row.name, count: row.branchCount })}
>
<span class="bp-chevron" aria-hidden="true">
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
@@ -535,7 +536,7 @@
</span>
<span class="bp-name">{row.name}</span>
{#if row.current && !open}
<span class="bp-current-dot" title="Contains current branch"></span>
<span class="bp-current-dot" title={t("branches.containsCurrent")}></span>
{/if}
<span class="bp-count">{row.branchCount}</span>
</button>
@@ -564,13 +565,13 @@
</span>
<span class="bp-meta">
{#if tracking.kind === "tracked"}
<span class="bp-track" aria-label={`Tracks ${tracking.upstream}`}><Cloud size={11} /></span>
<span class="bp-track" aria-label={t("branches.labelTracks", { upstream: tracking.upstream })}><Cloud size={11} /></span>
{:else if tracking.kind === "gone"}
<span class="bp-track gone" aria-label="Upstream gone"><CloudOff size={11} /></span>
<span class="bp-track gone" aria-label={t("branches.labelGone")}><CloudOff size={11} /></span>
{:else if tracking.kind === "local"}
<span class="bp-track local" aria-label="Local only"><Laptop size={11} /></span>
<span class="bp-track local" aria-label={t("branches.labelLocalOnly")}><Laptop size={11} /></span>
{:else if tracking.kind === "remote-tracked"}
<span class="bp-track linked" aria-label={`Checked out as ${tracking.local}`}><Link2 size={11} /></span>
<span class="bp-track linked" aria-label={t("branches.labelCheckedOut", { name: tracking.local })}><Link2 size={11} /></span>
{/if}
{#if row.branch.current}
<span class="bp-head-tag">HEAD</span>
@@ -581,8 +582,8 @@
type="button"
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
disabled={isBusy}
title="Branch actions"
aria-label={`Actions for ${row.branch.name}`}
title={t("branches.actions")}
aria-label={t("branches.actionsFor", { name: row.branch.name })}
>
<Ellipsis size={13} aria-hidden="true" />
</button>
@@ -591,17 +592,17 @@
{/each}
{/snippet}
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label={t("branches.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />Branches</h2>
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />{t("branches.title")}</h2>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
title={t("branches.create")}
aria-label={t("branches.create")}
>
<Plus size={14} aria-hidden="true" />
</button>
@@ -611,8 +612,8 @@
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand branches" : "Collapse branches"}
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
title={collapsed ? t("branches.expand") : t("branches.collapse")}
aria-label={collapsed ? t("branches.expandPanel") : t("branches.collapsePanel")}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
@@ -626,7 +627,7 @@
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
<p class="blank-state">{t("branches.openRepo")}</p>
{:else}
<div class="bp-body">
<div class="bp-top">
@@ -639,14 +640,14 @@
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="new-branch-name"
aria-label="New branch name"
placeholder={t("branches.namePlaceholder")}
aria-label={t("branches.nameLabel")}
onkeydown={(event) => { if (event.key === "Escape") closeCreateForm(); }}
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title={t("branches.createAction")}>
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title={t("common.cancel")}>
<X size={14} aria-hidden="true" />
</button>
</form>
@@ -654,7 +655,7 @@
{#if currentBranch}
{@const tracking = trackingFor(currentBranch)}
<button class="bp-current" type="button" onclick={revealCurrentBranch} title="Reveal current branch in list">
<button class="bp-current" type="button" onclick={revealCurrentBranch} title={t("branches.revealCurrent")}>
<span class="bp-current-icon" aria-hidden="true"><CircleDot size={14} /></span>
<span class="bp-current-text">
<strong>{currentBranch.name}</strong>
@@ -662,9 +663,9 @@
{#if tracking.kind === "tracked"}
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
{:else if tracking.kind === "gone"}
<CloudOff size={10} aria-hidden="true" /> {tracking.upstream} (gone)
<CloudOff size={10} aria-hidden="true" /> {t("branches.upstreamGone", { upstream: tracking.upstream })}
{:else}
<Laptop size={10} aria-hidden="true" /> Not published
<Laptop size={10} aria-hidden="true" /> {t("branches.notPublished")}
{/if}
</small>
</span>
@@ -680,18 +681,18 @@
type="text"
autocomplete="off"
spellcheck="false"
placeholder="Filter branches"
aria-label="Filter branches"
placeholder={t("branches.filter")}
aria-label={t("branches.filter")}
/>
{#if filterText}
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title="Clear filter" aria-label="Clear filter">
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title={t("branches.filterClear")} aria-label={t("branches.filterClear")}>
<X size={12} aria-hidden="true" />
</button>
{/if}
</label>
</div>
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label="Branch list">
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label={t("branches.listLabel")}>
<div class="bp-group">
<button
class="bp-group-head"
@@ -701,13 +702,13 @@
>
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Laptop size={12} aria-hidden="true" />
<span>Local</span>
<span>{t("common.local")}</span>
<span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
</button>
{#if showLocal}
{#if localBranches.length === 0}
<div class="bp-empty">No local branches.</div>
<div class="bp-empty">{t("branches.emptyLocal")}</div>
{:else}
{@render branchRows(localBranchRows)}
{/if}
@@ -723,13 +724,13 @@
>
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Cloud size={12} aria-hidden="true" />
<span>Remote</span>
<span>{t("common.remote")}</span>
<span class="bp-group-count">{filtering ? `${visibleRemote.length}/${remoteBranches.length}` : remoteBranches.length}</span>
</button>
{#if showRemote}
{#if remoteBranches.length === 0}
<div class="bp-empty">No remote branches.</div>
<div class="bp-empty">{t("branches.emptyRemote")}</div>
{:else}
{@render branchRows(remoteBranchRows)}
{/if}
@@ -737,7 +738,7 @@
</div>
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
<div class="bp-empty">No branches match “{filterText.trim()}”.</div>
<div class="bp-empty">{t("branches.noMatch", { query: filterText.trim() })}</div>
{/if}
</div>
</div>
@@ -750,32 +751,32 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
aria-label={t("branches.actionsFor", { name: contextBranch.name })}
>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
{t("common.checkout")}
</button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Compare with...
{t("branches.menuCompare")}
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
{t("branches.menuMerge")}
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
{t("branches.menuRebase")}
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
{t("branches.menuWorktree")}
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" />
{contextBranch.remote ? "Rename remote..." : "Rename"}
{contextBranch.remote ? t("branches.menuRenameRemote") : t("common.rename")}
</button>
<button
class="danger"
@@ -783,10 +784,10 @@
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
title={contextBranch.current ? t("branches.cannotDeleteCurrent") : contextBranch.remote ? t("branches.deleteRemoteBranch") : t("branches.deleteLocalBranch")}
>
<Trash2 size={14} aria-hidden="true" />
{contextBranch.remote ? "Delete remote" : "Delete"}
{contextBranch.remote ? t("branches.menuDeleteRemote") : t("common.delete")}
</button>
</div>
{/if}
@@ -798,7 +799,7 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for branch folder ${contextFolder.name}`}
aria-label={t("branches.folderActionsFor", { name: contextFolder.name })}
>
<button
class="danger"
@@ -806,10 +807,10 @@
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
title={contextFolder.current ? t("branches.folderKeepsCurrent") : t("branches.folderDeleteHint")}
>
<Trash2 size={14} aria-hidden="true" />
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
{t("branches.deleteFolder", { count: contextFolder.branches.filter((branch) => !branch.current).length })}
</button>
</div>
{/if}