feat(ui): localize commit AI and git error messages to English

Translate commit AI prompts, model labels, and git/keychain errors to
English so the app and generated messages are consistent. Also add a
discard confirmation dialog and update the UI text/styles to match the
new flow.

- src-tauri/crates/commit_ai/src/cloud.rs
  - Translate HTTP and API error messages to English.
  - Keep request timeout and token sizing behavior unchanged.
- src-tauri/crates/commit_ai/src/lib.rs
  - Translate model labels, prompt text, and validation errors.
  - Keep diff truncation and message sanitization logic intact.
- src-tauri/src/git.rs
  - Translate git, credential, merge, and history errors.
  - Update AI provider validation messages to English.
- src/lib/components/*
  - Update AI settings, commit panel, credential, and loading UI text.
  - Add discard confirmation dialog for destructive actions.
- src/App.svelte, src/app.css
  - Adjust app layout and styling for the new dialog and text changes.
This commit is contained in:
Christoph Brandau
2026-07-02 22:12:20 +02:00
parent 6b7186d040
commit 70de45e1ba
11 changed files with 537 additions and 187 deletions
+153 -16
View File
@@ -11,6 +11,7 @@
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
@@ -96,6 +97,9 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
interface RepoTab {
path: string;
@@ -110,6 +114,10 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
// ── State ──────────────────────────────────────────────────────────────────
@@ -157,6 +165,7 @@
let linePatchText = "";
let linePatchLoading = false;
let linePatchError = "";
let pendingDiscard: PendingDiscard | null = null;
let globalSearchOpen = false;
let lastSearchQuery = "";
let globalSearchResults: GitSearchHit[] = [];
@@ -186,6 +195,10 @@
let updateCheckInFlight = false;
let updateDownloadTotal = 0;
let updateDownloadedBytes = 0;
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
let resizeStartHeight = 0;
// ── Derived ────────────────────────────────────────────────────────────────
@@ -542,6 +555,56 @@
}
}
function clampCommitPanelHeight(value: number): number {
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
}
function loadCommitPanelHeight(): number {
try {
const stored = Number(localStorage.getItem(COMMIT_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampCommitPanelHeight(stored);
} catch {
// Fall through to the default below.
}
return COMMIT_PANEL_DEFAULT_HEIGHT;
}
function persistCommitPanelHeight(value: number) {
try {
localStorage.setItem(COMMIT_PANEL_HEIGHT_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
resizeStartY = event.clientY;
resizeStartHeight = commitPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onCommitPanelResizeMove(event: PointerEvent) {
if (!resizingCommitPanel) return;
commitPanelHeight = clampCommitPanelHeight(resizeStartHeight + (resizeStartY - event.clientY));
}
function endCommitPanelResize(event: PointerEvent) {
if (!resizingCommitPanel) return;
resizingCommitPanel = false;
persistCommitPanelHeight(commitPanelHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onCommitPanelResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
commitPanelHeight = clampCommitPanelHeight(commitPanelHeight + (event.key === "ArrowUp" ? 20 : -20));
persistCommitPanelHeight(commitPanelHeight);
}
function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists();
@@ -692,7 +755,7 @@
}
function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("abgebrochen");
return message.toLowerCase().includes("cancelled");
}
function cancelActiveFileHistoryLoad() {
@@ -766,7 +829,7 @@
if (isBusy) return;
try {
const selected = await openDialog({
title: "Repository folder auswaehlen",
title: "Select repository folder",
directory: true,
multiple: false,
defaultPath: repoPath.trim() || activeRepoPath || undefined,
@@ -975,7 +1038,7 @@
if (auth) {
if (key) void credDelete(key).catch(() => {});
credDialogError =
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden.";
"Credentials were rejected or have expired. Please sign in again.";
credDialogAction = action;
credDialogKey = key;
credDialogOpen = true;
@@ -984,7 +1047,7 @@
errorMessage = message;
}
} else {
credDialogError = message || "Anmeldung fehlgeschlagen.";
credDialogError = message || "Sign-in failed.";
}
}
@@ -1022,11 +1085,11 @@
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
errorMessage = "";
const shouldSync = window.confirm(
"Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt.\n\nJetzt Pull/Merge ausfuehren und danach den Push erneut versuchen?",
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
);
if (!shouldSync) {
const message = "Push abgelehnt: Der Remote hat neuere Commits. Pull zuerst ausfuehren, dann erneut pushen.";
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
if (fromStore) errorMessage = message;
else credDialogError = message;
return;
@@ -1050,7 +1113,7 @@
if (statusHasConflicts(status)) {
credDialogOpen = false;
credDialogAction = null;
errorMessage = "Pull hat Merge-Konflikte erzeugt. Loese die Konflikte, committe den Merge und pushe danach erneut.";
errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
return;
}
@@ -1125,7 +1188,12 @@
});
}
async function discardFile(file: GitFileStatus, staged: boolean) {
function discardFile(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath || isBusy) return;
pendingDiscard = { kind: "file", file, staged };
}
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
await runOperation(`Discarding ${file.path}`, async () => {
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
await refreshExplorerFiles(activeRepoPath);
@@ -1176,9 +1244,17 @@
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
function isDiscardPatchAction(action: PatchApplyAction): boolean {
return action === "discard-staged" || action === "discard-unstaged";
}
async function runLinePatchAction(
action: PatchApplyAction,
patch: string,
file: GitFileStatus,
staged: boolean,
) {
if (!activeRepoPath || isBusy) return;
operation = patchOperationLabel(action, file);
errorMessage = "";
linePatchError = "";
@@ -1188,7 +1264,7 @@
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
if (updatedPatch.trim()) {
linePatchText = updatedPatch;
} else {
@@ -1204,6 +1280,37 @@
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
const staged = linePatchStaged;
if (isDiscardPatchAction(action)) {
pendingDiscard = { kind: "hunk", file, staged, action, patch };
return;
}
await runLinePatchAction(action, patch, file, staged);
}
async function confirmDiscard() {
const discard = pendingDiscard;
if (!discard || !activeRepoPath || isBusy) return;
if (discard.kind === "file") {
await runDiscardFile(discard.file, discard.staged);
} else {
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
}
pendingDiscard = null;
}
function closeDiscardConfirm() {
if (isBusy) return;
pendingDiscard = null;
}
async function stageAllFiles() {
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
if (paths.length === 0) return;
@@ -1441,7 +1548,7 @@
} catch (error) {
if (globalSearchId === searchId) {
const message = errorToMessage(error);
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message;
globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message;
}
} finally {
if (globalSearchId === searchId) {
@@ -1454,7 +1561,7 @@
async function cancelGlobalSearch() {
if (!globalSearchId) return;
const searchId = globalSearchId;
globalSearchError = "Abbruch wird angefordert...";
globalSearchError = "Requesting cancellation...";
try {
await cancelCodeSearch(searchId);
} catch (error) {
@@ -1533,7 +1640,8 @@
// ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -1813,7 +1921,7 @@
</div>
</div>
<div class="top-section">
<div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
<StatusPanel
{changedFiles}
{stagedCount}
@@ -1830,6 +1938,24 @@
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle"
class:resizing={resizingCommitPanel}
role="separator"
aria-orientation="horizontal"
aria-label="Resize commit panel height"
aria-valuenow={commitPanelHeight}
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startCommitPanelResize}
onpointermove={onCommitPanelResizeMove}
onpointerup={endCommitPanelResize}
onpointercancel={endCommitPanelResize}
onkeydown={onCommitPanelResizeKeydown}
></div>
<CommitPanel
{commitMessage}
{canCommit}
@@ -1908,6 +2034,17 @@
/>
{/if}
{#if pendingDiscard}
<DiscardConfirmDialog
file={pendingDiscard.file}
staged={pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy}
onConfirm={confirmDiscard}
onClose={closeDiscardConfirm}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}