diff --git a/src/App.svelte b/src/App.svelte index 857c7b8..d4c42ab 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,8 @@ import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte"; import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry"; + import { setLanguage, t } from "./lib/i18n.svelte"; + import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte"; import TitleBar from "./lib/TitleBar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte"; @@ -22,17 +24,16 @@ import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; - import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import TagsPanel from "./lib/components/TagsPanel.svelte"; import WorktreePanel from "./lib/components/WorktreePanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; + import ConfirmDialog from "./lib/components/ConfirmDialog.svelte"; import CommandPalette from "./lib/components/CommandPalette.svelte"; import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.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 HistoryPanel from "./lib/components/HistoryPanel.svelte"; import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte"; @@ -427,6 +428,8 @@ let externalToolsDetectionUnavailable = false; let errorMessage = ""; let operation = ""; + let confirmDialogRequest: ConfirmRequest | null = null; + let confirmDialogResolve: ((confirmed: boolean) => void) | null = null; let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; @@ -1283,6 +1286,81 @@ function applyLanguagePreference(next: AppLanguage) { document.documentElement.lang = next; document.documentElement.dataset.language = next; + setLanguage(next); + } + + /** Show the confirmation dialog and resolve once the user answers. */ + function requestConfirmation(request: ConfirmRequest): Promise { + confirmDialogResolve?.(false); + confirmDialogRequest = request; + return new Promise((resolve) => { + confirmDialogResolve = resolve; + }); + } + + /** Confirmation shown before deleting a local or remote branch. */ + function branchDeleteConfirmRequest(branch: GitBranchInfo, force: boolean): ConfirmRequest { + const remoteName = branch.remote ? branch.name.split("/")[0] : ""; + return { + eyebrow: branch.remote + ? t("confirm.branchDelete.eyebrowRemote") + : force ? t("confirm.branchDelete.eyebrowForce") : t("confirm.branchDelete.eyebrow"), + title: branch.remote + ? t("confirm.branchDelete.titleRemote") + : force ? t("confirm.branchDelete.titleForce") : t("confirm.branchDelete.title"), + message: branch.remote + ? t("confirm.branchDelete.messageRemote") + : force ? t("confirm.branchDelete.messageForce") : t("confirm.branchDelete.message"), + items: [branch.name], + note: branch.remote + ? t("confirm.branchDelete.noteRemote", { remote: remoteName || t("common.remote") }) + : force ? t("confirm.branchDelete.noteForce") : t("confirm.branchDelete.note"), + confirmLabel: branch.remote + ? t("confirm.branchDelete.actionRemote") + : force ? t("confirm.branchDelete.actionForce") : t("confirm.branchDelete.action"), + }; + } + + /** Confirmation shown before discarding working-tree changes. */ + function discardConfirmRequest(discard: PendingDiscard): ConfirmRequest { + const files = discard.kind === "patch" ? [discard.file] : discard.files; + const staged = discard.kind === "all-changes" ? null : discard.staged; + const source = staged === null + ? t("confirm.discard.sourceBoth") + : staged ? t("confirm.discard.sourceStaged") : t("confirm.discard.sourceUnstaged"); + const scope = discard.kind === "patch" ? discard.scope : "file"; + + const title = scope === "hunk" + ? t("confirm.discard.titleHunk") + : scope === "lines" + ? t("confirm.discard.titleLines") + : files.length > 1 + ? t("confirm.discard.titleFiles", { count: files.length }) + : t("confirm.discard.titleFile"); + + const message = scope === "hunk" + ? t("confirm.discard.messageHunk", { source }) + : scope === "lines" + ? t("confirm.discard.messageLines", { source }) + : files.length > 1 + ? t("confirm.discard.messageFiles", { source, count: files.length }) + : t("confirm.discard.messageFile", { source }); + + return { + eyebrow: t("confirm.discard.eyebrow"), + title, + message, + items: files.map((file) => (file.old_path ? `${file.old_path} -> ${file.path}` : file.path)), + note: t("confirm.discard.note"), + confirmLabel: t("confirm.discard.action"), + }; + } + + function answerConfirmation(confirmed: boolean) { + const resolve = confirmDialogResolve; + confirmDialogRequest = null; + confirmDialogResolve = null; + resolve?.(confirmed); } function applyThemePreference(next: AppTheme) { @@ -3281,9 +3359,21 @@ return; } - const scope = remoteFolder ? "remote" : "local"; - const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : ""; - if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return; + const notes = [ + currentBranchKept ? t("confirm.branchFolder.noteCurrent") : "", + remoteFolder ? t("confirm.branchFolder.noteRemote") : "", + ].filter(Boolean); + const folderConfirmed = await requestConfirmation({ + title: deletableBranches.length === 1 + ? t("confirm.branchFolder.titleOne") + : t("confirm.branchFolder.title", { count: deletableBranches.length }), + message: deletableBranches.length === 1 + ? t(remoteFolder ? "confirm.branchFolder.messageRemoteOne" : "confirm.branchFolder.messageLocalOne", { folder: folderName }) + : t(remoteFolder ? "confirm.branchFolder.messageRemote" : "confirm.branchFolder.messageLocal", { folder: folderName }), + items: deletableBranches.map((branch) => branch.name), + note: notes.join(" ") || undefined, + }); + if (!folderConfirmed) return; const repoPath = activeRepoPath; if (remoteFolder) { @@ -3828,7 +3918,11 @@ async function abortRebase() { if (!activeRepoPath || !rebaseInProgress) return; - const confirmed = window.confirm("Abort the current rebase and return to the previous state?"); + const confirmed = await requestConfirmation({ + title: t("confirm.rebaseAbort.title"), + message: t("confirm.rebaseAbort.message"), + confirmLabel: t("confirm.rebaseAbort.action"), + }); if (!confirmed) return; await runOperation("Aborting rebase", async () => { @@ -3995,7 +4089,11 @@ async function deleteLocalTag(tag: GitTag) { if (!activeRepoPath || isBusy) return; - const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`); + const confirmed = await requestConfirmation({ + title: t("confirm.tagDelete.title", { name: tag.name }), + message: t("confirm.tagDelete.message"), + note: t("confirm.tagDelete.note"), + }); if (!confirmed) return; await runOperation(`Deleting tag ${tag.name}`, async () => { @@ -4188,7 +4286,11 @@ async function abortCherryPick() { if (!activeRepoPath || !cherryPickInProgress) return; - const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?"); + const confirmed = await requestConfirmation({ + title: t("confirm.cherryPickAbort.title"), + message: t("confirm.cherryPickAbort.message"), + confirmLabel: t("confirm.cherryPickAbort.action"), + }); if (!confirmed) return; await runOperation("Aborting cherry-pick", async () => { @@ -4366,9 +4468,13 @@ } errorMessage = ""; - const confirmed = window.confirm(appLanguage === "de" - ? "Das lokale und das entfernte Repository besitzen getrennte Commit-Historien.\n\nTrotzdem zusammenführen? Dabei können Merge-Konflikte entstehen." - : "The local and remote repositories have separate commit histories.\n\nMerge them anyway? This may produce merge conflicts."); + const confirmed = await requestConfirmation({ + title: t("confirm.unrelatedHistories.title"), + message: t("confirm.unrelatedHistories.message"), + note: t("confirm.unrelatedHistories.note"), + confirmLabel: t("confirm.unrelatedHistories.action"), + danger: false, + }); if (!confirmed) { errorMessage = appLanguage === "de" ? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert." @@ -4432,9 +4538,13 @@ if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) { errorMessage = ""; - const shouldSync = window.confirm( - "The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?", - ); + const shouldSync = await requestConfirmation({ + title: t("confirm.pushRejected.title"), + message: t("confirm.pushRejected.message"), + note: t("confirm.pushRejected.note"), + confirmLabel: t("confirm.pushRejected.action"), + danger: false, + }); if (!shouldSync) { const message = "Push rejected: the remote has newer commits. Pull first, then push again."; @@ -4620,7 +4730,15 @@ } async function revertHistoryCommit(commit: GitCommit) { - if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return; + if (!activeRepoPath) return; + const confirmed = await requestConfirmation({ + title: t("confirm.revert.title", { hash: commit.short_hash }), + message: t("confirm.revert.message"), + items: [commit.summary], + confirmLabel: t("confirm.revert.action"), + danger: false, + }); + if (!confirmed) return; await runOperation(`Reverting ${commit.short_hash}`, async () => { applyStatus(await revertCommit(activeRepoPath, commit.hash)); await refreshRepositoryViews(activeRepoPath, { branches: false }); @@ -4636,7 +4754,13 @@ } async function abortMerge() { - if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return; + if (!activeRepoPath) return; + const confirmed = await requestConfirmation({ + title: t("confirm.mergeAbort.title"), + message: t("confirm.mergeAbort.message"), + confirmLabel: t("confirm.mergeAbort.action"), + }); + if (!confirmed) return; await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshRepositoryViews(activeRepoPath); @@ -4649,7 +4773,13 @@ } async function forcePushRepo() { - if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return; + const confirmed = await requestConfirmation({ + title: t("confirm.forcePush.title"), + message: t("confirm.forcePush.message"), + note: t("confirm.forcePush.note"), + confirmLabel: t("confirm.forcePush.action"), + }); + if (!confirmed) return; remoteActionForceWithLease = true; await startRemoteAction("push"); } @@ -4747,7 +4877,12 @@ async function dropStashEntry(stash: GitStash) { if (!activeRepoPath) return; - const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`); + const confirmed = await requestConfirmation({ + title: t("confirm.stashDrop.title", { name: stash.selector }), + message: t("confirm.stashDrop.message"), + items: [stash.message || stash.selector], + note: t("confirm.stashDrop.note"), + }); if (!confirmed) return; await runOperation(`Dropping ${stash.selector}`, async () => { @@ -5095,9 +5230,13 @@ async function undoLastCommitChange() { if (!activeRepoPath || !canAmend || isBusy) return; - const confirmed = window.confirm( - "Undo the last commit?\n\nIts changes remain staged, ready to commit again. Your working tree files are preserved.", - ); + const confirmed = await requestConfirmation({ + title: t("confirm.undoCommit.title"), + message: t("confirm.undoCommit.message"), + note: t("confirm.undoCommit.note"), + confirmLabel: t("confirm.undoCommit.action"), + danger: false, + }); if (!confirmed) return; await runOperation("Undoing last commit", async () => { @@ -5116,7 +5255,13 @@ async function restoreCommit(target: GitCommit) { if (!activeRepoPath) return; - const confirmed = window.confirm(`Restore working tree to ${target.short_hash}?\n\nThis brings back the files from that commit as unstaged changes so you can review and commit them. No commit is removed and the branch stays where it is.`); + const confirmed = await requestConfirmation({ + title: t("confirm.restoreTree.title", { hash: target.short_hash }), + message: t("confirm.restoreTree.message"), + note: t("confirm.restoreTree.note"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return; await runOperation(`Restoring ${target.short_hash}`, async () => { applyStatus(await restoreToCommit(activeRepoPath, target.hash)); @@ -5127,7 +5272,12 @@ async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise { if (!activeRepoPath) return false; - const confirmed = window.confirm(`Restore ${file.path} from ${target.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`); + const confirmed = await requestConfirmation({ + title: t("confirm.restoreFile.title", { path: file.path, hash: target.short_hash }), + message: t("confirm.restoreFile.message"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return false; await runOperation(`Restoring ${file.path}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path)); @@ -5406,7 +5556,12 @@ async function restoreSelectedFileFromCommit(target: GitCommit) { if (!activeRepoPath || !selectedExplorerPath) return; const kind = selectedExplorerKind === "folder" ? "folder" : "file"; - const confirmed = window.confirm(`Restore ${kind} ${selectedExplorerPath} from ${target.short_hash}?\n\nThis changes the selected ${kind} in your working tree so you can review and commit it.`); + const confirmed = await requestConfirmation({ + title: t(kind === "folder" ? "confirm.restoreFile.titleFolder" : "confirm.restoreFile.title", { path: selectedExplorerPath, hash: target.short_hash }), + message: t("confirm.restoreFile.message"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return; await runOperation(`Restoring ${selectedExplorerPath}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath)); @@ -6481,14 +6636,20 @@ {/await} {/if} +{#if confirmDialogRequest} + answerConfirmation(true)} + onCancel={() => answerConfirmation(false)} + /> +{/if} + {#if pendingDiscard} - {/if} @@ -6623,12 +6784,11 @@ {#if deleteBranchTarget} - {/if} diff --git a/src/lib/components/AiSettingsPage.svelte b/src/lib/components/AiSettingsPage.svelte index b178e40..03ac616 100644 --- a/src/lib/components/AiSettingsPage.svelte +++ b/src/lib/components/AiSettingsPage.svelte @@ -3,6 +3,7 @@ import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte"; import { credDelete, credLoad, credSave } from "../git"; import type { AiSettings, CommitAiProvider } from "../types"; + import { t } from "../i18n.svelte"; interface Props { settings: AiSettings; @@ -81,7 +82,7 @@ async function persistKey(target: CloudProvider, value: string) { if (value === originalKeys[target]) return; - if (!keysLoaded) throw new Error("API keys could not be loaded. Existing credentials have been preserved."); + if (!keysLoaded) throw new Error(t("ai.keysNotLoaded")); const key = CRED_KEYS[target]; const trimmed = value.trim(); if (trimmed) { @@ -92,7 +93,7 @@ } export async function saveSettings(): Promise { - if (loadingKeys) throw new Error("Please wait for AI settings to load."); + if (loadingKeys) throw new Error(t("ai.waitForSettings")); saving = true; error = ""; try { @@ -119,7 +120,7 @@
-
+
{#if provider === "openai"}
- API key + {t("ai.apiKey")}