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:
+193
-33
@@ -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<boolean> {
|
||||
confirmDialogResolve?.(false);
|
||||
confirmDialogRequest = request;
|
||||
return new Promise<boolean>((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<boolean> {
|
||||
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}
|
||||
<ConfirmDialog
|
||||
request={confirmDialogRequest}
|
||||
onConfirm={() => answerConfirmation(true)}
|
||||
onCancel={() => answerConfirmation(false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
files={pendingDiscard.kind === "patch" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||
scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
|
||||
<ConfirmDialog
|
||||
request={discardConfirmRequest(pendingDiscard)}
|
||||
{isBusy}
|
||||
onConfirm={confirmDiscard}
|
||||
onClose={closeDiscardConfirm}
|
||||
onCancel={closeDiscardConfirm}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -6623,12 +6784,11 @@
|
||||
|
||||
<!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
|
||||
{#if deleteBranchTarget}
|
||||
<BranchDeleteConfirmDialog
|
||||
branch={deleteBranchTarget}
|
||||
force={deleteBranchForce}
|
||||
<ConfirmDialog
|
||||
request={branchDeleteConfirmRequest(deleteBranchTarget, deleteBranchForce)}
|
||||
{isBusy}
|
||||
onConfirm={confirmDeleteBranch}
|
||||
onClose={closeDeleteBranchDialog}
|
||||
onCancel={closeDeleteBranchDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -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<AiSettings> {
|
||||
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 @@
|
||||
</script>
|
||||
|
||||
<div class="ai-settings-form">
|
||||
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
||||
<div class="ai-provider-options" role="radiogroup" aria-label={t("ai.providerLabel")}>
|
||||
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||
<Bot size={16} aria-hidden="true" />
|
||||
OpenAI
|
||||
@@ -130,17 +131,17 @@
|
||||
</button>
|
||||
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
|
||||
<Globe size={16} aria-hidden="true" />
|
||||
Custom endpoint
|
||||
{t("ai.custom")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if provider === "openai"}
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<span class="cred-field-label">{t("ai.model")}</span>
|
||||
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<div class="cred-field">
|
||||
<span class="cred-field-label">API key</span>
|
||||
<span class="cred-field-label">{t("ai.apiKey")}</span>
|
||||
<div class="cred-input">
|
||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||
<input
|
||||
@@ -158,11 +159,11 @@
|
||||
</div>
|
||||
{:else if provider === "anthropic"}
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<span class="cred-field-label">{t("ai.model")}</span>
|
||||
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<div class="cred-field">
|
||||
<span class="cred-field-label">API key</span>
|
||||
<span class="cred-field-label">{t("ai.apiKey")}</span>
|
||||
<div class="cred-input">
|
||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||
<input
|
||||
@@ -180,21 +181,21 @@
|
||||
</div>
|
||||
{:else}
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Endpoint URL</span>
|
||||
<span class="cred-field-label">{t("ai.endpointUrl")}</span>
|
||||
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<span class="cred-field-label">{t("ai.model")}</span>
|
||||
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<div class="cred-field">
|
||||
<span class="cred-field-label">API key (optional)</span>
|
||||
<span class="cred-field-label">{t("ai.apiKeyOptional")}</span>
|
||||
<div class="cred-input">
|
||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
bind:value={customApiKey}
|
||||
placeholder="Optional"
|
||||
placeholder={t("ai.optional")}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={loadingKeys}
|
||||
@@ -206,7 +207,7 @@
|
||||
</div>
|
||||
<div class="cred-token-hint">
|
||||
<Globe size={13} aria-hidden="true" />
|
||||
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
|
||||
<span>{t("ai.customHint")}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {FileText, FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||
import type { GitBlameLine } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
@@ -75,7 +76,7 @@
|
||||
}
|
||||
|
||||
function groupTooltip(group: BlameGroup): string {
|
||||
if (group.isUncommitted) return "Not committed yet";
|
||||
if (group.isUncommitted) return t("blame.uncommitted");
|
||||
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||
}
|
||||
|
||||
@@ -132,16 +133,16 @@
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
|
||||
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label={t("blame.dialogLabel")}>
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><FileText size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Blame</span>
|
||||
<span class="eyebrow">{t("blame.eyebrow")}</span>
|
||||
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<span class="pill pill-count">{lines.length}</span>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,12 +152,12 @@
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading blame...
|
||||
{t("blame.loading")}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if lines.length === 0}
|
||||
<div class="blank-state">No blame information available for this file.</div>
|
||||
<div class="blank-state">{t("blame.empty")}</div>
|
||||
{:else}
|
||||
<div class="diff-header blame-code-header">
|
||||
<FileCode size={13} aria-hidden="true" />
|
||||
@@ -169,23 +170,23 @@
|
||||
bind:value={blameSearch}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Search blame"
|
||||
aria-label="Search blame"
|
||||
placeholder={t("blame.searchPlaceholder")}
|
||||
aria-label={t("blame.searchPlaceholder")}
|
||||
/>
|
||||
{#if searchActive}
|
||||
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
|
||||
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label={t("blame.searchClear")}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-col-headers blame-column-headers">
|
||||
<div class="split-col-label blame-commit-col-label">Commit</div>
|
||||
<div class="split-col-label blame-code-col-label">Code</div>
|
||||
<div class="split-col-label blame-commit-col-label">{t("blame.columnCommit")}</div>
|
||||
<div class="split-col-label blame-code-col-label">{t("blame.columnCode")}</div>
|
||||
</div>
|
||||
<div class="split-diff blame-diff" role="table" aria-label="File blame">
|
||||
<div class="split-diff blame-diff" role="table" aria-label={t("blame.dialogLabel")}>
|
||||
<div class="split-pane blame-scroll">
|
||||
{#if groups.length === 0}
|
||||
<div class="blank-state">No matches found.</div>
|
||||
<div class="blank-state">{t("blame.noMatches")}</div>
|
||||
{:else}
|
||||
<div class="blame-code-table">
|
||||
{#each groups as group (group.id)}
|
||||
@@ -197,7 +198,7 @@
|
||||
{/each}
|
||||
</span>
|
||||
<span class="blame-author">
|
||||
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
|
||||
{#each textSegments(group.isUncommitted ? t("blame.uncommitted") : group.authorName) as segment, index (`author-${group.id}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
interface Props {
|
||||
branch: GitBranchInfo;
|
||||
force: boolean;
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
branch,
|
||||
force = false,
|
||||
isBusy = false,
|
||||
onConfirm = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
|
||||
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
|
||||
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
|
||||
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
|
||||
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||
<header class="dialog-header branch-delete-header unified-dialog-header">
|
||||
<div class="branch-delete-heading unified-dialog-heading">
|
||||
<span class:force class="branch-delete-heading-icon unified-dialog-icon" aria-hidden="true">
|
||||
<Trash2 size={16} />
|
||||
</span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||
<p class="dialog-title" id="branch-delete-title">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="branch-delete-body">
|
||||
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p class="branch-delete-lead">
|
||||
{#if branch.remote}
|
||||
This branch will be removed from the shared remote repository.
|
||||
{:else if force}
|
||||
This branch is not fully merged. Some commits may only exist here.
|
||||
{:else}
|
||||
This branch will be removed from your local repository.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="branch-delete-target" title={branch.name}>
|
||||
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||
<span class="branch-delete-target-copy">
|
||||
<code>{branchName}</code>
|
||||
<span>{branchLocation}</span>
|
||||
</span>
|
||||
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
|
||||
</div>
|
||||
<p class="discard-warning-text">
|
||||
{#if branch.remote}
|
||||
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
|
||||
{:else if force}
|
||||
Force deletion can make unmerged commits difficult to recover.
|
||||
{:else}
|
||||
Git will stop the deletion if the branch contains unmerged commits.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Generic confirmation dialog. Replaces window.confirm so confirmations use
|
||||
* the app's own styling, translation and focus handling instead of a native,
|
||||
* untranslated, event-blocking browser dialog.
|
||||
*/
|
||||
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
export interface ConfirmRequest {
|
||||
/** Small label above the title. */
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
/** Leading sentence explaining what happens. */
|
||||
message: string;
|
||||
/** Items the action applies to, rendered as a scrollable list. */
|
||||
items?: string[];
|
||||
/** Extra warning below the list. */
|
||||
note?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Optional opt-in the user must tick before confirming, e.g. "delete anyway". */
|
||||
checkbox?: { label: string; note?: string; required?: boolean };
|
||||
/** Destructive actions get the red confirm button and warning icon. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
request: ConfirmRequest;
|
||||
isBusy?: boolean;
|
||||
/** `checked` is the state of the optional checkbox. */
|
||||
onConfirm: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { request, isBusy = false, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
const MAX_VISIBLE_ITEMS = 8;
|
||||
|
||||
let dialogElement = $state<HTMLElement | null>(null);
|
||||
let confirmButton = $state<HTMLButtonElement | null>(null);
|
||||
let danger = $derived(request.danger !== false);
|
||||
let items = $derived(request.items ?? []);
|
||||
let checked = $state(false);
|
||||
let blocked = $derived(Boolean(request.checkbox?.required) && !checked);
|
||||
|
||||
$effect(() => {
|
||||
// Reset the opt-in whenever a different confirmation is shown.
|
||||
request.title;
|
||||
checked = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
confirmButton?.focus();
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
if (!isBusy) onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab" || !dialogElement) return;
|
||||
|
||||
const focusable = [...dialogElement.querySelectorAll<HTMLElement>("button:not(:disabled)")];
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div bind:this={dialogElement} class:danger class="dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true">
|
||||
{#if danger}<Trash2 size={18} />{:else}<Check size={18} />{/if}
|
||||
</span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">{request.eyebrow ?? t("confirm.eyebrow")}</span>
|
||||
<p class="dialog-title" id="confirm-dialog-title">{request.title}</p>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onCancel} disabled={isBusy} aria-label={t("common.close")}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="discard-confirm-body">
|
||||
<div class="discard-warning-icon" aria-hidden="true">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p class="confirm-lead">{request.message}</p>
|
||||
|
||||
{#if items.length > 0}
|
||||
<ul class="discard-target-list">
|
||||
{#each items.slice(0, MAX_VISIBLE_ITEMS) as item (item)}
|
||||
<li><code class="discard-target" title={item}>{item}</code></li>
|
||||
{/each}
|
||||
{#if items.length > MAX_VISIBLE_ITEMS}
|
||||
<li class="discard-target-more">{items.length - MAX_VISIBLE_ITEMS === 1 ? t("confirm.moreOne") : t("confirm.more", { count: items.length - MAX_VISIBLE_ITEMS })}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if request.checkbox}
|
||||
<label class="confirm-check">
|
||||
<input type="checkbox" bind:checked disabled={isBusy} />
|
||||
<span>
|
||||
<strong>{request.checkbox.label}</strong>
|
||||
{#if request.checkbox.note}<small>{request.checkbox.note}</small>{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if request.note}
|
||||
<p class="discard-warning-text">{request.note}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onCancel} disabled={isBusy}>
|
||||
{request.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
bind:this={confirmButton}
|
||||
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
|
||||
type="button"
|
||||
onclick={() => onConfirm(checked)}
|
||||
disabled={isBusy || blocked}
|
||||
>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else if danger}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{request.confirmLabel ?? (danger ? t("common.delete") : t("common.confirm"))}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Matches .discard-confirm-dialog / .branch-delete-dialog so every confirmation
|
||||
in the app has the same size, chrome and rhythm. */
|
||||
.confirm-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
width: min(500px, calc(100vw - 32px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.confirm-dialog.danger {
|
||||
border-color: rgba(255, 90, 103, 0.22);
|
||||
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
|
||||
}
|
||||
.confirm-dialog.danger .dialog-header {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
|
||||
var(--app-dialog-chrome);
|
||||
}
|
||||
.confirm-dialog .discard-confirm-body { padding: 20px 18px 18px; }
|
||||
.confirm-dialog .confirm-lead {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-dialog.danger .unified-dialog-icon {
|
||||
border-color: rgba(255, 90, 103, 0.28);
|
||||
color: #ff9aa4;
|
||||
background: rgba(255, 90, 103, 0.09);
|
||||
}
|
||||
.confirm-dialog .discard-target-list { max-height: 148px; }
|
||||
.confirm-dialog .discard-warning-text {
|
||||
padding: 9px 10px;
|
||||
border-left: 2px solid rgba(255, 90, 103, 0.55);
|
||||
color: #f2aeb5;
|
||||
background: rgba(255, 90, 103, 0.055);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-dialog:not(.danger) .discard-warning-text {
|
||||
border-left-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
color: var(--color-ink-muted);
|
||||
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||
}
|
||||
.confirm-dialog:not(.danger) .discard-warning-icon {
|
||||
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||
}
|
||||
.confirm-dialog .confirm-action { min-width: 116px; }
|
||||
.confirm-dialog .confirm-check {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(255, 90, 103, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 90, 103, 0.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: #e86060; }
|
||||
.confirm-dialog .confirm-check span { display: grid; gap: 2px; min-width: 0; }
|
||||
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
|
||||
.confirm-dialog .confirm-check small { color: var(--color-ink-dim); font-size: 11.5px; }
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {Trash2, AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk" | "lines";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
onConfirm = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
let count = $derived(files.length);
|
||||
let title = $derived(
|
||||
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||
);
|
||||
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
|
||||
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><Trash2 size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Confirm discard</span>
|
||||
<p class="dialog-title">{title}</p>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="discard-confirm-body">
|
||||
<div class="discard-warning-icon" aria-hidden="true">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</p>
|
||||
{#if count > 1}
|
||||
<ul class="discard-target-list">
|
||||
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||
{/each}
|
||||
{#if files.length > 8}
|
||||
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if count === 1}
|
||||
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||
{/if}
|
||||
<p class="discard-warning-text">
|
||||
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
Discard
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,6 +17,8 @@
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
status: GitLfsStatus | null;
|
||||
@@ -81,11 +83,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPrune() {
|
||||
const confirmed = window.confirm(isGerman
|
||||
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
||||
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
||||
if (confirmed) await onPrune();
|
||||
let pruneConfirmOpen = $state(false);
|
||||
|
||||
async function runPrune() {
|
||||
pruneConfirmOpen = false;
|
||||
await onPrune();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -199,7 +201,7 @@
|
||||
<footer class="lfs-footer">
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||
<button class="btn-secondary" type="button" onclick={() => { pruneConfirmOpen = true; }} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -293,3 +295,16 @@
|
||||
.lfs-footer button { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if pruneConfirmOpen}
|
||||
<ConfirmDialog
|
||||
request={{
|
||||
title: t("confirm.lfsPrune.title"),
|
||||
message: t("confirm.lfsPrune.message"),
|
||||
note: t("confirm.lfsPrune.note"),
|
||||
confirmLabel: t("confirm.lfsPrune.action"),
|
||||
}}
|
||||
onConfirm={runPrune}
|
||||
onCancel={() => { pruneConfirmOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||
import { visibleParentResolver } from "../graphParents";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface GraphSegment {
|
||||
fromCol: number;
|
||||
@@ -310,11 +311,11 @@
|
||||
|
||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||
if (directBranches.length > 0) return t("history.hoverBranches", { list: directBranches.join(", ") });
|
||||
|
||||
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||
if (containingBranches.length === 0) return commit.short_hash;
|
||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||
return t("history.hoverContaining", { list: containingBranches.join(", ") });
|
||||
}
|
||||
|
||||
function segmentIsVisible(segment: GraphSegment): boolean {
|
||||
@@ -516,7 +517,7 @@
|
||||
const note = await onLoadCommitNote(commit);
|
||||
notePreviews = {
|
||||
...notePreviews,
|
||||
[commit.hash]: note?.trim() || "This Git note is empty.",
|
||||
[commit.hash]: note?.trim() || t("history.noteEmpty"),
|
||||
};
|
||||
} catch {
|
||||
const nextErrors = new Set(notePreviewErrors);
|
||||
@@ -684,14 +685,14 @@
|
||||
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||
if (branch.localOnly) {
|
||||
const status = branchStatusLabel(branch);
|
||||
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||
return `${t("history.branchLocalOnlyTitle", { name: branch.label })}${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
const status = branchStatusLabel(branch);
|
||||
if (branch.trackedRemote) {
|
||||
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||
return `${t("history.branchTracksTitle", { name: branch.label, upstream: branch.trackedRemote })}${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
if (status) return `${branch.label} · ${status}`;
|
||||
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||
return branch.kind === "remote" ? t("history.branchRemoteTitle", { name: branch.label }) : t("history.branchLocalTitle", { name: branch.label });
|
||||
}
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
@@ -778,11 +779,11 @@
|
||||
|
||||
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
||||
|
||||
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
||||
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("history.panelLabel")}>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">History</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
<span class="eyebrow">{t("history.eyebrow")}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("history.title")}</h2>
|
||||
</div>
|
||||
{#if graphBranchNames.length > 0}
|
||||
<div class="section-head-actions">
|
||||
@@ -790,8 +791,8 @@
|
||||
class="graph-branch-dialog-button"
|
||||
type="button"
|
||||
onclick={openBranchDialog}
|
||||
title="Customize visible branches"
|
||||
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||
title={t("history.customizeBranches")}
|
||||
aria-label={t("history.visibleBranches", { visible: visibleBranchCount, total: graphBranchNames.length })}
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
Branches
|
||||
@@ -802,13 +803,13 @@
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
<div class="blank-state">{t("history.noRepo")}</div>
|
||||
{:else if commits.length === 0}
|
||||
<div class="blank-state">No commits returned.</div>
|
||||
<div class="blank-state">{t("history.noCommits")}</div>
|
||||
{:else}
|
||||
<div class="history-list graph-list overflow-auto">
|
||||
{#if visibleCommits.length === 0}
|
||||
<div class="blank-state">No loaded commits match the selected branches.</div>
|
||||
<div class="blank-state">{t("history.noMatchingCommits")}</div>
|
||||
{/if}
|
||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||
{@const item = entry.commit}
|
||||
@@ -884,7 +885,7 @@
|
||||
{/if}
|
||||
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||
<div class="commit-ref-area">
|
||||
<div class="commit-ref-strip" aria-label="Commit references">
|
||||
<div class="commit-ref-strip" aria-label={t("history.refs")}>
|
||||
{#if refSummary.primaryBranch}
|
||||
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||
<span
|
||||
@@ -900,14 +901,14 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if refSummary.primaryBranch.localOnly}
|
||||
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||
LOCAL
|
||||
<span class="compact-ref-local-marker" title={t("history.localOnlyHint")}>
|
||||
{t("history.localOnlyBadge")}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if refSummary.primaryTag}
|
||||
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||
<span class="compact-ref-chip tag" title={t("history.tagTitle", { name: refSummary.primaryTag })}>
|
||||
<Tag size={10} aria-hidden="true" />
|
||||
<span>{refSummary.primaryTag}</span>
|
||||
</span>
|
||||
@@ -919,7 +920,7 @@
|
||||
onclick={() => toggleCommitRefs(item)}
|
||||
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||
aria-controls={`commit-refs-${item.hash}`}
|
||||
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||
title={refSummary.overflowCount === 1 ? t("history.showMoreRefsOne") : t("history.showMoreRefs", { count: refSummary.overflowCount })}
|
||||
>
|
||||
+{refSummary.overflowCount}
|
||||
</button>
|
||||
@@ -928,17 +929,17 @@
|
||||
|
||||
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||
<strong>References on this commit</strong>
|
||||
<strong>{t("history.refsOnCommit")}</strong>
|
||||
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||
<section>
|
||||
<span>Local</span>
|
||||
<span>{t("common.local")}</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||
<i aria-hidden="true"></i>{branch.label}
|
||||
{#if branch.current}<small>Current</small>{/if}
|
||||
{#if branch.current}<small>{t("history.current")}</small>{/if}
|
||||
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />{t("history.localOnly")}</small>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -946,7 +947,7 @@
|
||||
{/if}
|
||||
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||
<section>
|
||||
<span>Remote</span>
|
||||
<span>{t("common.remote")}</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||
@@ -956,7 +957,7 @@
|
||||
{/if}
|
||||
{#if refSummary.tags.length > 0}
|
||||
<section>
|
||||
<span>Tags</span>
|
||||
<span>{t("history.tags")}</span>
|
||||
<div>
|
||||
{#each refSummary.tags as tag}
|
||||
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||
@@ -966,7 +967,7 @@
|
||||
{/if}
|
||||
{#if refSummary.other.length > 0}
|
||||
<section>
|
||||
<span>Other</span>
|
||||
<span>{t("history.other")}</span>
|
||||
<div>
|
||||
{#each refSummary.other as ref}
|
||||
<span class="commit-ref-detail-item">{ref}</span>
|
||||
@@ -1005,11 +1006,11 @@
|
||||
onfocus={() => void loadCommitNotePreview(item)}
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Open Git note for ${item.short_hash}`}
|
||||
aria-label={t("history.openNote", { hash: item.short_hash })}
|
||||
aria-describedby={`commit-note-preview-${item.hash}`}
|
||||
>
|
||||
<StickyNote size={11} aria-hidden="true" />
|
||||
<span>Note</span>
|
||||
<span>{t("history.note")}</span>
|
||||
</button>
|
||||
<span
|
||||
class="commit-note-tooltip"
|
||||
@@ -1018,8 +1019,8 @@
|
||||
>
|
||||
<span class="commit-note-tooltip-head">
|
||||
<StickyNote size={12} aria-hidden="true" />
|
||||
Git Note
|
||||
<small>Click to open</small>
|
||||
{t("history.gitNote")}
|
||||
<small>{t("history.clickToOpen")}</small>
|
||||
</span>
|
||||
<span class="commit-note-tooltip-body">
|
||||
{#if notePreviewLoading.has(item.hash)}
|
||||
@@ -1054,14 +1055,14 @@
|
||||
</button>
|
||||
|
||||
{#if expandedCommitHashes.has(item.hash)}
|
||||
<div class="commit-file-list" aria-label="Changed files">
|
||||
<div class="commit-file-list" aria-label={t("history.changedFiles")}>
|
||||
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
||||
<button
|
||||
class="commit-file-button"
|
||||
type="button"
|
||||
onclick={() => onPreviewCommitFile(item, file)}
|
||||
disabled={isBusy}
|
||||
title={`Show differences before restoring - ${displayCommitFile(file)}`}
|
||||
title={t("history.diffBeforeRestore", { file: displayCommitFile(file) })}
|
||||
>
|
||||
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||
<strong>{commitFileName(file)}</strong>
|
||||
@@ -1081,8 +1082,8 @@
|
||||
type="button"
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
title={`Add a Git note to ${item.short_hash}`}
|
||||
aria-label={`Add a Git note to ${item.short_hash}`}
|
||||
title={t("history.addNote", { hash: item.short_hash })}
|
||||
aria-label={t("history.addNote", { hash: item.short_hash })}
|
||||
>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1092,8 +1093,8 @@
|
||||
type="button"
|
||||
onclick={(event) => openCommitActionMenu(event, item)}
|
||||
disabled={isBusy}
|
||||
title="Commit actions"
|
||||
aria-label={`Actions for ${item.short_hash}`}
|
||||
title={t("history.commitActions")}
|
||||
aria-label={t("history.actionsFor", { hash: item.short_hash })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={contextCommit?.hash === item.hash}
|
||||
>
|
||||
@@ -1108,13 +1109,13 @@
|
||||
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
||||
{#if isLoadingMore}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
<span>Loading older commits…</span>
|
||||
<span>{t("history.loadingOlder")}</span>
|
||||
{:else if loadMoreError}
|
||||
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
||||
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
||||
<span title={loadMoreError}>{t("history.loadOlderFailed")}</span>
|
||||
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>{t("history.retry")}</button>
|
||||
{:else}
|
||||
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
||||
Load older commits
|
||||
{t("history.loadOlder")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1128,32 +1129,32 @@
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextCommit.short_hash}`}
|
||||
aria-label={t("history.actionsFor", { hash: contextCommit.short_hash })}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Branch
|
||||
{t("history.menuBranch")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
Note
|
||||
{t("history.note")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
{t("history.menuRestore")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={cherryPickContextCommit}
|
||||
disabled={isBusy}
|
||||
title="Apply this commit's changes on top of the current branch"
|
||||
title={t("history.menuCherryPickHint")}
|
||||
>
|
||||
<Cherry size={14} aria-hidden="true" />
|
||||
Cherry-pick
|
||||
{t("history.menuCherryPick")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
||||
<RotateCcw size={14} aria-hidden="true" /> Revert
|
||||
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title={t("history.menuRevertHint")}>
|
||||
<RotateCcw size={14} aria-hidden="true" /> {t("history.menuRevert")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1165,25 +1166,25 @@
|
||||
class="branch-filter-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select visible branches"
|
||||
aria-label={t("history.branchDialogLabel")}
|
||||
>
|
||||
<header class="branch-filter-dialog-head unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><GitBranch size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Git graph</span>
|
||||
<h3>Visible branches</h3>
|
||||
<span class="eyebrow">{t("history.graphEyebrow")}</span>
|
||||
<h3>{t("history.graphTitle")}</h3>
|
||||
</div>
|
||||
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
|
||||
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label={t("history.closeBranchDialog")}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="branch-filter-summary">
|
||||
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||
<span>{t("history.branchesSelected", { visible: visibleBranchCount, total: graphBranchNames.length })}</span>
|
||||
<div class="branch-filter-actions">
|
||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>{t("history.focus")}</button>
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>{t("history.showAll")}</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>{t("history.hideAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1197,7 +1198,7 @@
|
||||
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
||||
>
|
||||
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Local</span>
|
||||
<span>{t("common.local")}</span>
|
||||
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
|
||||
</button>
|
||||
{#if localBranchGroupOpen}
|
||||
@@ -1226,7 +1227,7 @@
|
||||
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
||||
>
|
||||
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Remote</span>
|
||||
<span>{t("common.remote")}</span>
|
||||
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
|
||||
</button>
|
||||
{#if remoteBranchGroupOpen}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
|
||||
import type { ReflogEntry } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
entries: ReflogEntry[];
|
||||
@@ -32,21 +33,21 @@
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
|
||||
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label={t("reflog.title")} tabindex="-1">
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><History size={18} /></span>
|
||||
<div class="unified-dialog-text"><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
||||
<div class="unified-dialog-text"><span class="eyebrow">{t("reflog.eyebrow")}</span><h2 class="dialog-title">{t("reflog.title")}</h2></div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.close")}><X size={18} aria-hidden="true" /></button>
|
||||
</header>
|
||||
<div class="reflog-body">
|
||||
<aside class="reflog-list-pane">
|
||||
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
|
||||
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder={t("reflog.searchPlaceholder")} aria-label={t("reflog.searchLabel")} /></label>
|
||||
{#if isLoading}
|
||||
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
|
||||
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("reflog.loading")}</div>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<div class="blank-state">No reflog entries match this search.</div>
|
||||
<div class="blank-state">{t("reflog.noMatch")}</div>
|
||||
{:else}
|
||||
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
|
||||
<div class="reflog-list" role="listbox" aria-label={t("reflog.listLabel")}>
|
||||
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
|
||||
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
|
||||
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
|
||||
@@ -62,18 +63,18 @@
|
||||
{#if error}<div class="rebase-warning error">{error}</div>{/if}
|
||||
{#if selected}
|
||||
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
|
||||
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
|
||||
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
|
||||
<dl><div><dt>{t("common.commit")}</dt><dd><code>{selected.hash}</code></dd></div><div><dt>{t("reflog.author")}</dt><dd>{selected.author_name}</dd></div><div><dt>{t("reflog.date")}</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
|
||||
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> {t("reflog.preview")}</button>
|
||||
<div class="reflog-recovery-card">
|
||||
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
|
||||
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
|
||||
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>{t("reflog.safeRecovery")}</strong><span>{t("reflog.safeRecoveryNote")}</span></div></div>
|
||||
<label><span>{t("reflog.recoveryBranch")}</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
|
||||
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
|
||||
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
|
||||
Create and checkout recovery branch
|
||||
{t("reflog.createBranch")}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
|
||||
<div class="blank-state">{t("reflog.selectEntry")}</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMount } from "svelte";
|
||||
import type { AiSettings } from "../types";
|
||||
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
||||
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
|
||||
import { t } from "../i18n.svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
@@ -340,16 +342,35 @@
|
||||
return de ? "Request wieder öffnen" : "Reopen request";
|
||||
}
|
||||
|
||||
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
|
||||
let reviewConfirmResolve: ((confirmed: boolean) => void) | null = null;
|
||||
|
||||
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<boolean> {
|
||||
const values = { number: request.number };
|
||||
reviewConfirmRequest = action === "merge"
|
||||
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false }
|
||||
: action === "close"
|
||||
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
|
||||
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
reviewConfirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function answerReviewConfirmation(confirmed: boolean) {
|
||||
const resolve = reviewConfirmResolve;
|
||||
reviewConfirmRequest = null;
|
||||
reviewConfirmResolve = null;
|
||||
resolve?.(confirmed);
|
||||
}
|
||||
|
||||
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||
const source = activeSource;
|
||||
if (!source || actionBusyId) return;
|
||||
if (action !== "approve") {
|
||||
const prompt = action === "merge"
|
||||
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
|
||||
: action === "close"
|
||||
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
|
||||
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
|
||||
if (!window.confirm(prompt)) return;
|
||||
const confirmed = await askReviewConfirmation(request, action);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
actionMenuId = "";
|
||||
actionNotice = "";
|
||||
@@ -636,3 +657,11 @@
|
||||
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
|
||||
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
|
||||
</style>
|
||||
|
||||
{#if reviewConfirmRequest}
|
||||
<ConfirmDialog
|
||||
request={reviewConfirmRequest}
|
||||
onConfirm={() => answerReviewConfirmation(true)}
|
||||
onCancel={() => answerReviewConfirmation(false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
|
||||
import type { GitStash } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
stashes: GitStash[];
|
||||
@@ -42,11 +43,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
|
||||
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label={t("stashes.title")}>
|
||||
<div class="section-head">
|
||||
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />Stashes</h2>
|
||||
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />{t("stashes.title")}</h2>
|
||||
<div class="stash-head-actions">
|
||||
<button class="stash-toggle" type="button" title="Create stash" aria-label="Create stash"
|
||||
<button class="stash-toggle" type="button" title={t("stashes.create")} aria-label={t("stashes.create")}
|
||||
disabled={!hasRepository || isBusy || changedCount === 0}
|
||||
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
@@ -57,8 +58,8 @@
|
||||
type="button"
|
||||
onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
||||
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
||||
title={collapsed ? t("stashes.expand") : t("stashes.collapse")}
|
||||
aria-label={collapsed ? t("stashes.expand") : t("stashes.collapse")}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
@@ -72,7 +73,7 @@
|
||||
{#if collapsed}
|
||||
<!-- collapsed -->
|
||||
{:else if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
<div class="blank-state">{t("stashes.noRepo")}</div>
|
||||
{:else}
|
||||
{#if createOpen}
|
||||
<div class="stash-create">
|
||||
@@ -80,8 +81,8 @@
|
||||
class="stash-input"
|
||||
type="text"
|
||||
bind:value={message}
|
||||
placeholder="Optional message"
|
||||
aria-label="Stash message"
|
||||
placeholder={t("stashes.messagePlaceholder")}
|
||||
aria-label={t("stashes.messageLabel")}
|
||||
disabled={isBusy || changedCount === 0}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
|
||||
@@ -89,24 +90,24 @@
|
||||
/>
|
||||
<label class="stash-check">
|
||||
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
|
||||
Untracked
|
||||
{t("stashes.untracked")}
|
||||
</label>
|
||||
<button
|
||||
class="btn-sm stash-save-button"
|
||||
type="button"
|
||||
onclick={submitPush}
|
||||
disabled={isBusy || changedCount === 0}
|
||||
title="Save current working tree changes to a stash"
|
||||
title={t("stashes.saveHint")}
|
||||
>
|
||||
<Archive size={14} aria-hidden="true" />
|
||||
Stash
|
||||
{t("stashes.save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
{#if stashes.length === 0}
|
||||
<div class="blank-state stash-empty">No stashes saved.</div>
|
||||
<div class="blank-state stash-empty">{t("stashes.empty")}</div>
|
||||
{:else}
|
||||
<div class="stash-list">
|
||||
{#each stashes as stash (stash.selector)}
|
||||
@@ -116,7 +117,7 @@
|
||||
<span>
|
||||
{stash.selector}
|
||||
{#if stash.branch}
|
||||
on {stash.branch}
|
||||
{t("stashes.on", { branch: stash.branch })}
|
||||
{/if}
|
||||
{#if stash.date}
|
||||
- {stash.date}
|
||||
@@ -124,17 +125,17 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="stash-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
|
||||
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title={t("stashes.applyHint")}>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
Apply
|
||||
{t("stashes.apply")}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
|
||||
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title={t("stashes.popHint")}>
|
||||
<Upload size={13} aria-hidden="true" />
|
||||
Pop
|
||||
{t("stashes.pop")}
|
||||
</button>
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title={t("stashes.dropHint")}>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
Drop
|
||||
{t("stashes.drop")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
} from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
changedFiles: GitFileStatus[];
|
||||
@@ -246,7 +247,7 @@
|
||||
function statusContextParent(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
|
||||
return separator > 0 ? normalized.slice(0, separator) : t("status.repositoryRoot");
|
||||
}
|
||||
|
||||
function closeStatusContextMenu() {
|
||||
@@ -410,58 +411,58 @@
|
||||
|
||||
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
||||
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("status.panelLabel")}>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||
<span class="eyebrow">{t("status.eyebrow")}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("status.title")}</h2>
|
||||
</div>
|
||||
<div class="status-view-switch" role="group" aria-label="Changes view">
|
||||
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
|
||||
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
|
||||
<div class="status-view-switch" role="group" aria-label={t("status.viewGroup")}>
|
||||
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title={t("status.viewList")} aria-label={t("status.viewList")} aria-pressed={statusView === "list"}>
|
||||
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>{t("status.viewListShort")}</span>
|
||||
</button>
|
||||
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
|
||||
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
|
||||
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title={t("status.viewTree")} aria-label={t("status.viewTree")} aria-pressed={statusView === "tree"}>
|
||||
<FolderTree size={13} aria-hidden="true" /><span>{t("status.viewTreeShort")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||
<span class="pill pill-count">{t("status.staged", { count: stagedCount })}</span>
|
||||
<span class="pill pill-count">{t("status.unstaged", { count: unstagedCount })}</span>
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
||||
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title={t("status.discardAllHint")}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> {t("status.discardAll")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
<div class="blank-state">{t("status.noRepo")}</div>
|
||||
{:else if status?.clean}
|
||||
<div class="blank-state">Working tree is clean.</div>
|
||||
<div class="blank-state">{t("status.clean")}</div>
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
<div class="blank-state">{t("status.noChanges")}</div>
|
||||
{:else}
|
||||
<div class="status-lanes">
|
||||
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
|
||||
<section class="status-lane unstaged-lane" aria-label={t("status.laneUnstaged")}>
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
|
||||
<div class="status-lane-copy"><strong>{t("status.unstagedTitle")}</strong><small>{t("status.workingTree")}</small></div>
|
||||
<span class="status-lane-count">{unstagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={t("status.stageSelected", { count: selectedUnstagedCount })}>
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage all
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title={t("status.stageAllHint")}>
|
||||
<ArrowRight size={13} aria-hidden="true" /> {t("status.stageAll")}
|
||||
</button>
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={t("status.discardUnstagedSelected", { count: selectedUnstagedCount })}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -479,20 +480,20 @@
|
||||
{@const file = row.file}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.stageFile")}><ArrowRight size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title={t("status.showUnstagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.discardUnstaged")}><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">{t("status.emptyUnstaged")}</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -500,25 +501,25 @@
|
||||
<span><ArrowRight size={12} /></span>
|
||||
</div>
|
||||
|
||||
<section class="status-lane staged-lane" aria-label="Staged changes">
|
||||
<section class="status-lane staged-lane" aria-label={t("status.laneStaged")}>
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
|
||||
<div class="status-lane-copy"><strong>{t("status.stagedTitle")}</strong><small>{t("status.nextCommit")}</small></div>
|
||||
<span class="status-lane-count">{stagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedStagedCount > 1}
|
||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={t("status.unstageSelected", { count: selectedStagedCount })}>
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
|
||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title={t("status.unstageAllHint")}>
|
||||
<ArrowLeft size={13} aria-hidden="true" /> {t("status.unstageAll")}
|
||||
</button>
|
||||
{#if selectedStagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={t("status.discardStagedSelected", { count: selectedStagedCount })}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedStagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -536,20 +537,20 @@
|
||||
{@const file = row.file}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.unstageFile")}><ArrowLeft size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title={t("status.showStagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.discardStaged")}><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">{t("status.emptyStaged")}</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -568,7 +569,7 @@
|
||||
</svg>
|
||||
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
||||
</div>
|
||||
<span class="status-panel-overlay-label">{operation || "Working"}…</span>
|
||||
<span class="status-panel-overlay-label">{operation || t("status.working")}…</span>
|
||||
<div class="status-panel-overlay-bar"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,17 +577,17 @@
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={t("status.menuActionsFor", { name: statusContextTarget.label })} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
</span>
|
||||
<span class="status-context-object-copy">
|
||||
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
|
||||
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : t("status.menuKindUnstagedFile")) : (statusContextTarget.kind === "folder" ? t("status.menuKindStagedFolder") : t("status.menuKindStagedFile"))}</span>
|
||||
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
|
||||
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||
</span>
|
||||
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
|
||||
<span class="status-context-count" title={statusContextTarget.files.length === 1 ? t("status.menuFileCountOne") : t("status.menuFileCount", { count: statusContextTarget.files.length })}>
|
||||
{statusContextTarget.files.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -595,56 +596,56 @@
|
||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
||||
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
|
||||
<strong>{statusContextTarget.lane === "unstaged" ? (statusContextTarget.kind === "folder" ? t("status.menuStageFolder") : t("status.menuStageFile")) : (statusContextTarget.kind === "folder" ? t("status.menuUnstageFolder") : t("status.menuUnstageFile"))}</strong>
|
||||
<span>{statusContextTarget.lane === "unstaged" ? t("status.menuStageHint") : t("status.menuUnstageHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stash {statusContextTarget.kind}</strong>
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : t("status.menuStashFile")}</strong>
|
||||
<span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if statusContextCanStopTracking}
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title={t("status.menuStopTrackingHint")}>
|
||||
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
||||
<span>Keep it on disk and remove it from Git</span>
|
||||
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : t("status.menuStopTrackingFile")}</strong>
|
||||
<span>{t("status.menuStopTrackingNote")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextCanIgnore}
|
||||
{#if statusContextTarget.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={t("status.menuIgnoreFileHint", { path: statusContextTarget.label.replace(/\\/g, "/") })}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore file</strong>
|
||||
<span>Add only this file to .gitignore</span>
|
||||
<strong>{t("status.menuIgnoreFile")}</strong>
|
||||
<span>{t("status.menuIgnoreFileNote")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextIgnoreExtension}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={t("status.menuIgnoreExtHint", { ext: statusContextIgnoreExtension })}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
||||
<span>Match this file type repository-wide</span>
|
||||
<strong>{t("status.menuIgnoreExt", { ext: statusContextIgnoreExtension })}</strong>
|
||||
<span>{t("status.menuIgnoreExtNote")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={t("status.menuIgnoreFolderHint", { path: statusContextIgnoreFolder })}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore folder</strong>
|
||||
<span>Add this folder and its contents to .gitignore</span>
|
||||
<strong>{t("status.menuIgnoreFolder")}</strong>
|
||||
<span>{t("status.menuIgnoreFolderNote")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { tick } from "svelte";
|
||||
import type { GitTag } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
interface Props {
|
||||
tags: GitTag[];
|
||||
hasRepository: boolean;
|
||||
@@ -93,21 +94,21 @@
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeTagContextMenu} on:keydown={(event) => { if (event.key === "Escape") closeTagContextMenu(); }} on:contextmenu|capture={closeTagContextMenu} />
|
||||
<section class="panel tags-panel" class:collapsed aria-label="Tags">
|
||||
<section class="panel tags-panel" class:collapsed aria-label={t("tags.title")}>
|
||||
<div class="section-head">
|
||||
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />Tags</h2>
|
||||
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />{t("tags.title")}</h2>
|
||||
<div class="branch-head-actions">
|
||||
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title="Create new tag" aria-label="Create new tag"><Plus size={14} aria-hidden="true" /></button>
|
||||
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title={t("tags.create")} aria-label={t("tags.create")}><Plus size={14} aria-hidden="true" /></button>
|
||||
<span class="pill pill-count">{tags.length}</span>
|
||||
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed} title={collapsed ? "Expand tags" : "Collapse tags"} aria-label={collapsed ? "Expand tags" : "Collapse tags"}>
|
||||
aria-expanded={!collapsed} title={collapsed ? t("tags.expand") : t("tags.collapse")} aria-label={collapsed ? t("tags.expand") : t("tags.collapse")}>
|
||||
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
<div class="sidebar-tags-list">
|
||||
{#if !hasRepository}<p class="branch-empty">Open a repository to list tags.</p>
|
||||
{#if !hasRepository}<p class="branch-empty">{t("tags.openRepo")}</p>
|
||||
{:else}
|
||||
{#if tagCreateOpen}
|
||||
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
||||
@@ -119,27 +120,27 @@
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="v1.0.0"
|
||||
aria-label="New tag name"
|
||||
aria-label={t("tags.nameLabel")}
|
||||
/>
|
||||
<input
|
||||
bind:value={newTagMessage}
|
||||
disabled={isBusy}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Message (optional)"
|
||||
aria-label="Tag message"
|
||||
placeholder={t("tags.messagePlaceholder")}
|
||||
aria-label={t("tags.messageLabel")}
|
||||
/>
|
||||
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
|
||||
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title={t("tags.createAction")}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
|
||||
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title={t("common.cancel")}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if tags.length === 0}
|
||||
<div class="branch-empty">No tags.</div>
|
||||
<div class="branch-empty">{t("tags.empty")}</div>
|
||||
{:else}
|
||||
{#each tags as tag (tag.name)}
|
||||
<article
|
||||
@@ -167,16 +168,16 @@
|
||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextTag.name}`}
|
||||
aria-label={t("tags.actionsFor", { name: contextTag.name })}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
Push to remote
|
||||
{t("tags.push")}
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
|
||||
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title={t("tags.deleteLocal")}>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
|
||||
type CreateMode = "existing" | "new" | "detached";
|
||||
@@ -99,7 +101,7 @@
|
||||
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
|
||||
|
||||
function displayName(worktree: GitWorktree): string {
|
||||
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
|
||||
return worktree.branch || (worktree.detached ? t("worktreeDialog.detachedAt", { head: worktree.short_head || "HEAD" }) : t("worktreeDialog.bare"));
|
||||
}
|
||||
|
||||
function pathName(path: string): string {
|
||||
@@ -117,7 +119,7 @@
|
||||
|
||||
async function chooseDestination(current = "") {
|
||||
const selected = await openDialog({
|
||||
title: current ? "Choose new worktree location" : "Choose worktree folder",
|
||||
title: current ? t("worktreeDialog.chooseNewLocation") : t("worktreeDialog.chooseFolder"),
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: current || undefined,
|
||||
@@ -175,14 +177,33 @@
|
||||
forceRemoval = false;
|
||||
}
|
||||
|
||||
async function confirmRemoval() {
|
||||
async function confirmRemoval(force: boolean) {
|
||||
if (!pendingRemoval) return;
|
||||
if (await onRemove(pendingRemoval, forceRemoval)) {
|
||||
forceRemoval = force;
|
||||
if (await onRemove(pendingRemoval, force)) {
|
||||
pendingRemoval = null;
|
||||
forceRemoval = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Same shape as every other delete confirmation in the app. */
|
||||
function removalConfirmRequest(worktree: GitWorktree): ConfirmRequest {
|
||||
return {
|
||||
eyebrow: t("worktreeDialog.removeEyebrow"),
|
||||
title: t("confirm.worktreeRemove.title", { name: displayName(worktree) }),
|
||||
message: t("confirm.worktreeRemove.message"),
|
||||
items: [worktree.path],
|
||||
checkbox: worktree.clean
|
||||
? undefined
|
||||
: {
|
||||
label: t("worktreeDialog.removeForce"),
|
||||
note: t("worktreeDialog.removeForceNote", { count: worktree.changed_files }),
|
||||
required: true,
|
||||
},
|
||||
confirmLabel: t("confirm.worktreeRemove.action"),
|
||||
};
|
||||
}
|
||||
|
||||
function requestLock(worktree: GitWorktree) {
|
||||
pendingLock = worktree;
|
||||
lockReason = "";
|
||||
@@ -203,28 +224,28 @@
|
||||
<div class="worktree-dialog-heading unified-dialog-heading">
|
||||
<span class="worktree-dialog-mark unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Parallel workspaces</span>
|
||||
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
|
||||
<span class="eyebrow">{t("worktreeDialog.eyebrow")}</span>
|
||||
<p class="dialog-title" id="worktree-dialog-title">{t("worktrees.title")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title={t("worktreeDialog.refresh")}>
|
||||
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||
Refresh
|
||||
{t("worktreeDialog.refreshShort")}
|
||||
</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="worktree-summary">
|
||||
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
|
||||
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
|
||||
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
|
||||
<div><strong>{linkedCount}</strong><span>{t("worktreeDialog.linked")}</span></div>
|
||||
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>{t("worktreeDialog.withChanges")}</span></div>
|
||||
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>{t("worktreeDialog.stale")}</span></div>
|
||||
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
New worktree
|
||||
{t("worktreeDialog.new")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -237,33 +258,33 @@
|
||||
<form class="worktree-create-card" onsubmit={submitCreate}>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Create</span>
|
||||
<h3>Choose what this workspace should track</h3>
|
||||
<span class="eyebrow">{t("worktreeDialog.createEyebrow")}</span>
|
||||
<h3>{t("worktreeDialog.createTitle")}</h3>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
|
||||
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label={t("worktreeDialog.closeCreate")}>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
|
||||
<div class="worktree-mode-tabs" role="tablist" aria-label={t("worktreeDialog.typeLabel")}>
|
||||
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
|
||||
<GitBranch size={14} aria-hidden="true" />Existing branch
|
||||
<GitBranch size={14} aria-hidden="true" />{t("worktreeDialog.existingBranch")}
|
||||
</button>
|
||||
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
|
||||
<Plus size={14} aria-hidden="true" />New branch
|
||||
<Plus size={14} aria-hidden="true" />{t("worktreeDialog.newBranch")}
|
||||
</button>
|
||||
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
|
||||
<CircleDot size={14} aria-hidden="true" />Detached
|
||||
<CircleDot size={14} aria-hidden="true" />{t("worktreeDialog.detached")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="worktree-create-fields">
|
||||
{#if createMode === "existing"}
|
||||
<label>
|
||||
<span>Branch</span>
|
||||
<span>{t("common.branch")}</span>
|
||||
<SelectMenu
|
||||
value={selectedBranch}
|
||||
placeholder="Select a local branch"
|
||||
placeholder={t("worktreeDialog.selectBranch")}
|
||||
options={localBranches.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
|
||||
@@ -275,26 +296,26 @@
|
||||
</label>
|
||||
{:else if createMode === "new"}
|
||||
<label>
|
||||
<span>New branch name</span>
|
||||
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
|
||||
<span>{t("worktreeDialog.newBranchName")}</span>
|
||||
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.newBranchPlaceholder")} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Start point</span>
|
||||
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
|
||||
<span>{t("worktreeDialog.startPoint")}</span>
|
||||
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.startPointPlaceholder")} />
|
||||
</label>
|
||||
{:else}
|
||||
<label>
|
||||
<span>Commit or ref</span>
|
||||
<span>{t("worktreeDialog.commitOrRef")}</span>
|
||||
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="worktree-path-field">
|
||||
<span>Folder</span>
|
||||
<span>{t("worktreeDialog.folder")}</span>
|
||||
<div>
|
||||
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
|
||||
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.folderPlaceholder")} />
|
||||
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
|
||||
<FolderOpen size={15} aria-hidden="true" />Browse
|
||||
<FolderOpen size={15} aria-hidden="true" />{t("worktreeDialog.browse")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
@@ -303,7 +324,7 @@
|
||||
<footer>
|
||||
<label class="worktree-check">
|
||||
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
|
||||
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
|
||||
<span><strong>{t("worktreeDialog.lockAfterCreate")}</strong><small>{t("worktreeDialog.lockAfterCreateNote")}</small></span>
|
||||
</label>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@@ -311,16 +332,16 @@
|
||||
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
|
||||
>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
|
||||
Create worktree
|
||||
{t("worktreeDialog.createAction")}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if isLoading && worktrees.length === 0}
|
||||
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
|
||||
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{t("worktreeDialog.loading")}</span></div>
|
||||
{:else if worktrees.length === 0}
|
||||
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
|
||||
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>{t("worktreeDialog.emptyTitle")}</strong><span>{t("worktreeDialog.emptyNote")}</span></div>
|
||||
{:else}
|
||||
<div class="worktree-list">
|
||||
{#each worktrees as worktree (worktree.path)}
|
||||
@@ -339,11 +360,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="worktree-badges">
|
||||
{#if worktree.is_main}<span>Main</span>{/if}
|
||||
{#if worktree.is_current}<span class="active">Open</span>{/if}
|
||||
{#if worktree.detached}<span>Detached</span>{/if}
|
||||
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
|
||||
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
|
||||
{#if worktree.is_main}<span>{t("worktreeDialog.main")}</span>{/if}
|
||||
{#if worktree.is_current}<span class="active">{t("worktreeDialog.open")}</span>{/if}
|
||||
{#if worktree.detached}<span>{t("worktreeDialog.detached")}</span>{/if}
|
||||
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />{t("worktreeDialog.locked")}</span>{/if}
|
||||
{#if worktree.prunable || worktree.missing}<span class="danger">{t("worktreeDialog.staleBadge")}</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -360,31 +381,31 @@
|
||||
|
||||
<footer>
|
||||
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
|
||||
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
|
||||
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? t("worktreeDialog.refreshTab") : t("worktreeDialog.openTab")}
|
||||
</button>
|
||||
<div class="worktree-card-actions">
|
||||
{#if worktree.prunable}
|
||||
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
|
||||
<Wrench size={14} aria-hidden="true" />Repair
|
||||
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title={t("worktreeDialog.repairHint")}>
|
||||
<Wrench size={14} aria-hidden="true" />{t("worktreeDialog.repair")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !worktree.is_main && !worktree.missing}
|
||||
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
|
||||
<FolderInput size={14} aria-hidden="true" />Move
|
||||
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title={t("worktreeDialog.moveHint")}>
|
||||
<FolderInput size={14} aria-hidden="true" />{t("worktreeDialog.move")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if worktree.locked}
|
||||
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
|
||||
<Unlock size={14} aria-hidden="true" />Unlock
|
||||
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title={t("worktreeDialog.unlockHint")}>
|
||||
<Unlock size={14} aria-hidden="true" />{t("worktreeDialog.unlock")}
|
||||
</button>
|
||||
{:else if !worktree.is_main}
|
||||
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
|
||||
<Lock size={14} aria-hidden="true" />Lock
|
||||
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title={t("worktreeDialog.lockHint")}>
|
||||
<Lock size={14} aria-hidden="true" />{t("worktreeDialog.lock")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !worktree.is_main}
|
||||
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
|
||||
<Trash2 size={14} aria-hidden="true" />Remove
|
||||
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? t("worktreeDialog.removePruneHint") : t("worktreeDialog.removeHint")}>
|
||||
<Trash2 size={14} aria-hidden="true" />{t("worktreeDialog.remove")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -397,7 +418,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="worktree-dialog-footer">
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>{t("worktreeDialog.protectedNote")}</span></div>
|
||||
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
|
||||
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
|
||||
</button>
|
||||
@@ -406,37 +427,12 @@
|
||||
</div>
|
||||
|
||||
{#if pendingRemoval}
|
||||
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
||||
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Remove worktree</span>
|
||||
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy} aria-label="Cancel removal"><X size={18} /></button>
|
||||
</header>
|
||||
<div class="worktree-confirm-body">
|
||||
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
|
||||
<div>
|
||||
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
|
||||
<code>{pendingRemoval.path}</code>
|
||||
{#if !pendingRemoval.clean}
|
||||
<label class="worktree-check danger">
|
||||
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
|
||||
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
|
||||
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
|
||||
<Trash2 size={15} aria-hidden="true" />Remove worktree
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
request={removalConfirmRequest(pendingRemoval)}
|
||||
{isBusy}
|
||||
onConfirm={(force) => { void confirmRemoval(force); }}
|
||||
onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingLock}
|
||||
@@ -445,20 +441,20 @@
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">Protect worktree</span>
|
||||
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
|
||||
<span class="eyebrow">{t("worktreeDialog.lockEyebrow")}</span>
|
||||
<p class="dialog-title" id="worktree-lock-title">{t("worktreeDialog.lockTitle", { name: displayName(pendingLock) })}</p>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label="Cancel locking"><X size={18} /></button>
|
||||
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label={t("worktreeDialog.cancelLock")}><X size={18} /></button>
|
||||
</header>
|
||||
<div class="worktree-lock-body">
|
||||
<label>
|
||||
<span>Reason <small>optional</small></span>
|
||||
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
|
||||
<span>{t("worktreeDialog.reason")} <small>{t("worktreeDialog.optional")}</small></span>
|
||||
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder={t("worktreeDialog.reasonPlaceholder")} />
|
||||
</label>
|
||||
</div>
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
|
||||
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
|
||||
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>{t("common.cancel")}</button>
|
||||
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />{t("worktreeDialog.lock")}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
|
||||
import type { GitWorktree } from "../types";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
worktrees: GitWorktree[];
|
||||
@@ -19,18 +20,18 @@
|
||||
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
|
||||
</script>
|
||||
|
||||
<section class="panel worktree-panel" class:collapsed aria-label="Worktrees" aria-busy={loading}>
|
||||
<section class="panel worktree-panel" class:collapsed aria-label={t("worktrees.title")} aria-busy={loading}>
|
||||
<div class="section-head">
|
||||
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />Worktrees</h2>
|
||||
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />{t("worktrees.title")}</h2>
|
||||
<div class="branch-head-actions">
|
||||
<button class="branch-create-toggle" type="button" onclick={onManage} disabled={!hasRepository || isBusy}
|
||||
title="Create or manage worktrees" aria-label="Create or manage worktrees" aria-haspopup="dialog">
|
||||
title={t("worktrees.manage")} aria-label={t("worktrees.manage")} aria-haspopup="dialog">
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="pill pill-count">{loading && linkedWorktrees.length === 0 ? "…" : linkedWorktrees.length}</span>
|
||||
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed} title={collapsed ? "Expand worktrees" : "Collapse worktrees"}
|
||||
aria-label={collapsed ? "Expand worktrees" : "Collapse worktrees"}>
|
||||
aria-expanded={!collapsed} title={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}
|
||||
aria-label={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}>
|
||||
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -38,30 +39,30 @@
|
||||
{#if !collapsed}
|
||||
<div class="sidebar-worktree-list">
|
||||
{#if !hasRepository}
|
||||
<p class="branch-empty">Open a repository to list worktrees.</p>
|
||||
<p class="branch-empty">{t("worktrees.openRepo")}</p>
|
||||
{:else if error}
|
||||
<div class="sidebar-worktree-error" role="status">
|
||||
<span>{error}</span>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />Retry</button>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />{t("worktrees.retry")}</button>
|
||||
</div>
|
||||
{:else if loading && linkedWorktrees.length === 0}
|
||||
<p class="branch-empty" role="status">Loading worktrees…</p>
|
||||
<p class="branch-empty" role="status">{t("worktrees.loading")}</p>
|
||||
{:else if linkedWorktrees.length === 0}
|
||||
<p class="branch-empty">No linked worktrees.</p>
|
||||
<p class="branch-empty">{t("worktrees.empty")}</p>
|
||||
{:else}
|
||||
{#each linkedWorktrees as worktree (worktree.path)}
|
||||
<button class="sidebar-worktree-row" class:current={worktree.is_current} type="button"
|
||||
onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}
|
||||
aria-current={worktree.is_current ? "location" : undefined}
|
||||
title={`${worktree.path}${worktree.missing ? " — missing" : worktree.bare ? " — bare repository" : ""}`}>
|
||||
title={`${worktree.path}${worktree.missing ? t("worktrees.missingSuffix") : worktree.bare ? t("worktrees.bareSuffix") : ""}`}>
|
||||
<HardDrive size={15} aria-hidden="true" />
|
||||
<span class="sidebar-worktree-info">
|
||||
<strong>{name(worktree.path)}</strong>
|
||||
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? "Bare repository" : `Detached · ${worktree.short_head || "HEAD"}`)}</span>
|
||||
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? t("worktrees.bare") : t("worktrees.detached", { head: worktree.short_head || "HEAD" }))}</span>
|
||||
</span>
|
||||
{#if worktree.locked}<Lock size={12} aria-label="Locked" />{/if}
|
||||
{#if worktree.missing}<span class="pill">Missing</span>
|
||||
{:else if worktree.is_current}<span class="pill pill-active">Current</span>{/if}
|
||||
{#if worktree.locked}<Lock size={12} aria-label={t("worktrees.locked")} />{/if}
|
||||
{#if worktree.missing}<span class="pill">{t("worktrees.missing")}</span>
|
||||
{:else if worktree.is_current}<span class="pill pill-active">{t("worktrees.current")}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Central translation helper.
|
||||
*
|
||||
* The active language lives in module scope so components can call `t(...)`
|
||||
* without threading a `language` prop through every layer. Reading `t(...)`
|
||||
* inside markup registers a dependency on `activeLanguage`, so switching the
|
||||
* language in settings re-renders every translated string.
|
||||
*/
|
||||
import { messages, type MessageEntry, type MessageKey } from "./messages";
|
||||
import type { AppLanguage } from "./types";
|
||||
|
||||
let activeLanguage = $state<AppLanguage>("en");
|
||||
|
||||
export function setLanguage(next: AppLanguage) {
|
||||
activeLanguage = next;
|
||||
}
|
||||
|
||||
export function getLanguage(): AppLanguage {
|
||||
return activeLanguage;
|
||||
}
|
||||
|
||||
export function isGermanLanguage(): boolean {
|
||||
return activeLanguage === "de";
|
||||
}
|
||||
|
||||
export type TranslationValues = Record<string, string | number>;
|
||||
|
||||
/** Look up `key` in the active language and fill in `{placeholders}`. */
|
||||
export function t(key: MessageKey, values?: TranslationValues): string {
|
||||
const entry: MessageEntry | undefined = messages[key];
|
||||
let text: string = entry ? entry[activeLanguage] ?? entry.en : key;
|
||||
|
||||
if (values) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
text = text.split(`{${name}}`).join(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Pick the singular or plural key based on `count` and pass it as `{count}`. */
|
||||
export function tPlural(one: MessageKey, many: MessageKey, count: number, values?: TranslationValues): string {
|
||||
return t(count === 1 ? one : many, { count, ...values });
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* User-facing strings, English first with the German translation beside it.
|
||||
* Keys are grouped by the area they appear in. Use `{name}` for placeholders.
|
||||
*/
|
||||
export const messages = {
|
||||
// ── Shared wording ─────────────────────────────────────────────────────────
|
||||
"common.cancel": { en: "Cancel", de: "Abbrechen" },
|
||||
"common.close": { en: "Close", de: "Schließen" },
|
||||
"common.delete": { en: "Delete", de: "Löschen" },
|
||||
"common.rename": { en: "Rename", de: "Umbenennen" },
|
||||
"common.checkout": { en: "Checkout", de: "Auschecken" },
|
||||
"common.local": { en: "Local", de: "Lokal" },
|
||||
"common.remote": { en: "Remote", de: "Remote" },
|
||||
"common.confirm": { en: "Confirm", de: "Bestätigen" },
|
||||
"common.branch": { en: "Branch", de: "Branch" },
|
||||
"common.commit": { en: "Commit", de: "Commit" },
|
||||
|
||||
// ── Confirmation dialog ────────────────────────────────────────────────────
|
||||
"confirm.eyebrow": { en: "Please confirm", de: "Bitte bestätigen" },
|
||||
"confirm.more": { en: "+{count} more", de: "+{count} weitere" },
|
||||
"confirm.moreOne": { en: "+1 more", de: "+1 weiterer" },
|
||||
|
||||
// ── Confirmations ──────────────────────────────────────────────────────────
|
||||
"confirm.branchFolder.title": { en: "Delete {count} branches?", de: "{count} Branches löschen?" },
|
||||
"confirm.branchFolder.titleOne": { en: "Delete 1 branch?", de: "1 Branch löschen?" },
|
||||
"confirm.branchFolder.messageLocalOne": { en: "This local branch in the folder “{folder}” will be deleted.", de: "Dieser lokale Branch im Ordner „{folder}“ wird gelöscht." },
|
||||
"confirm.branchFolder.messageRemoteOne": { en: "This remote branch in the folder “{folder}” will be deleted on the remote.", de: "Dieser Remote-Branch im Ordner „{folder}“ wird auf dem Remote gelöscht." },
|
||||
"confirm.branchFolder.messageLocal": { en: "These local branches in the folder “{folder}” will be deleted.", de: "Diese lokalen Branches im Ordner „{folder}“ werden gelöscht." },
|
||||
"confirm.branchFolder.messageRemote": { en: "These remote branches in the folder “{folder}” will be deleted on the remote.", de: "Diese Remote-Branches im Ordner „{folder}“ werden auf dem Remote gelöscht." },
|
||||
"confirm.branchFolder.noteCurrent": { en: "The current branch stays and is not deleted.", de: "Der aktuelle Branch bleibt erhalten und wird nicht gelöscht." },
|
||||
"confirm.branchFolder.noteRemote": { en: "This affects everyone working with this remote.", de: "Das betrifft alle, die mit diesem Remote arbeiten." },
|
||||
"confirm.rebaseAbort.title": { en: "Abort rebase?", de: "Rebase abbrechen?" },
|
||||
"confirm.rebaseAbort.message": { en: "The rebase stops and the repository returns to the state it had before it started.", de: "Der Rebase wird gestoppt und das Repository kehrt in den Zustand davor zurück." },
|
||||
"confirm.rebaseAbort.action": { en: "Abort rebase", de: "Rebase abbrechen" },
|
||||
"confirm.cherryPickAbort.title": { en: "Abort cherry-pick?", de: "Cherry-Pick abbrechen?" },
|
||||
"confirm.cherryPickAbort.message": { en: "The cherry-pick stops and the repository returns to the state it had before it started.", de: "Der Cherry-Pick wird gestoppt und das Repository kehrt in den Zustand davor zurück." },
|
||||
"confirm.cherryPickAbort.action": { en: "Abort cherry-pick", de: "Cherry-Pick abbrechen" },
|
||||
"confirm.mergeAbort.title": { en: "Abort merge?", de: "Merge abbrechen?" },
|
||||
"confirm.mergeAbort.message": { en: "The merge stops and your files return to the state they had before it started.", de: "Der Merge wird gestoppt und deine Dateien kehren in den Zustand davor zurück." },
|
||||
"confirm.mergeAbort.action": { en: "Abort merge", de: "Merge abbrechen" },
|
||||
"confirm.tagDelete.title": { en: "Delete tag {name}?", de: "Tag {name} löschen?" },
|
||||
"confirm.tagDelete.message": { en: "The tag is removed from your local repository.", de: "Das Tag wird aus deinem lokalen Repository entfernt." },
|
||||
"confirm.tagDelete.note": { en: "A copy already pushed to a remote is kept.", de: "Eine bereits gepushte Kopie auf einem Remote bleibt bestehen." },
|
||||
"confirm.stashDrop.title": { en: "Delete stash {name}?", de: "Stash {name} löschen?" },
|
||||
"confirm.stashDrop.message": { en: "The stashed changes are deleted.", de: "Die gestashten Änderungen werden gelöscht." },
|
||||
"confirm.stashDrop.note": { en: "This cannot be undone.", de: "Das lässt sich nicht rückgängig machen." },
|
||||
"confirm.forcePush.title": { en: "Force push?", de: "Force-Push ausführen?" },
|
||||
"confirm.forcePush.message": { en: "The current branch is pushed with --force-with-lease, overwriting the remote branch with your history.", de: "Der aktuelle Branch wird mit --force-with-lease gepusht und überschreibt den Remote-Branch mit deiner Historie." },
|
||||
"confirm.forcePush.note": { en: "Intended for a branch whose history you rebased. Commits others pushed in the meantime can be lost.", de: "Gedacht für einen Branch, dessen Historie du umgeschrieben hast. Commits, die andere inzwischen gepusht haben, können verloren gehen." },
|
||||
"confirm.forcePush.action": { en: "Force push", de: "Force-Push" },
|
||||
"confirm.revert.title": { en: "Revert commit {hash}?", de: "Commit {hash} rückgängig machen?" },
|
||||
"confirm.revert.message": { en: "A new commit is created that reverses the changes of this commit. Nothing is removed from the history.", de: "Es wird ein neuer Commit erstellt, der die Änderungen dieses Commits zurücknimmt. Aus der Historie wird nichts entfernt." },
|
||||
"confirm.revert.action": { en: "Revert", de: "Rückgängig machen" },
|
||||
"confirm.undoCommit.title": { en: "Undo last commit?", de: "Letzten Commit rückgängig machen?" },
|
||||
"confirm.undoCommit.message": { en: "The commit is removed, its changes stay staged and ready to commit again.", de: "Der Commit wird entfernt, seine Änderungen bleiben gestaged und können erneut committet werden." },
|
||||
"confirm.undoCommit.note": { en: "Your working tree files are kept.", de: "Die Dateien im Arbeitsverzeichnis bleiben erhalten." },
|
||||
"confirm.undoCommit.action": { en: "Undo commit", de: "Commit zurücknehmen" },
|
||||
"confirm.restoreTree.title": { en: "Restore working tree to {hash}?", de: "Arbeitsverzeichnis auf {hash} zurücksetzen?" },
|
||||
"confirm.restoreTree.message": { en: "The files from that commit come back as unstaged changes, ready for you to review and commit.", de: "Die Dateien aus diesem Commit kommen als ungestagte Änderungen zurück, bereit zum Prüfen und Committen." },
|
||||
"confirm.restoreTree.note": { en: "No commit is removed and the branch stays where it is.", de: "Es wird kein Commit entfernt und der Branch bleibt, wo er ist." },
|
||||
"confirm.restoreTree.action": { en: "Restore", de: "Wiederherstellen" },
|
||||
"confirm.restoreFile.title": { en: "Restore {path} from {hash}?", de: "{path} aus {hash} wiederherstellen?" },
|
||||
"confirm.restoreFile.titleFolder": { en: "Restore folder {path} from {hash}?", de: "Ordner {path} aus {hash} wiederherstellen?" },
|
||||
"confirm.restoreFile.message": { en: "This overwrites the version in your working tree so you can review and commit it.", de: "Das überschreibt die Version in deinem Arbeitsverzeichnis, damit du sie prüfen und committen kannst." },
|
||||
"confirm.unrelatedHistories.title": { en: "Merge separate histories?", de: "Getrennte Historien zusammenführen?" },
|
||||
"confirm.unrelatedHistories.message": { en: "The local and the remote repository have separate commit histories.", de: "Das lokale und das entfernte Repository haben getrennte Commit-Historien." },
|
||||
"confirm.unrelatedHistories.note": { en: "Merging them anyway may produce merge conflicts.", de: "Ein Zusammenführen kann Merge-Konflikte erzeugen." },
|
||||
"confirm.unrelatedHistories.action": { en: "Merge anyway", de: "Trotzdem zusammenführen" },
|
||||
"confirm.pushRejected.title": { en: "Pull first?", de: "Zuerst pullen?" },
|
||||
"confirm.pushRejected.message": { en: "The remote has newer commits, so the push was rejected.", de: "Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt." },
|
||||
"confirm.pushRejected.note": { en: "Run Pull/Merge now and push again afterwards?", de: "Jetzt Pull/Merge ausführen und danach erneut pushen?" },
|
||||
"confirm.pushRejected.action": { en: "Pull and push", de: "Pullen und pushen" },
|
||||
"confirm.lfsPrune.title": { en: "Prune LFS cache?", de: "LFS-Cache bereinigen?" },
|
||||
"confirm.lfsPrune.message": { en: "Local LFS objects that are no longer needed are removed from the cache.", de: "Nicht mehr benötigte lokale LFS-Objekte werden aus dem Cache entfernt." },
|
||||
"confirm.lfsPrune.note": { en: "Unpushed and currently used objects are kept.", de: "Nicht gepushte und aktuell verwendete Objekte bleiben erhalten." },
|
||||
"confirm.lfsPrune.action": { en: "Prune cache", de: "Cache bereinigen" },
|
||||
"confirm.review.mergeTitle": { en: "Merge request #{number}?", de: "Request #{number} zusammenführen?" },
|
||||
"confirm.review.mergeMessage": { en: "The request is merged in the connected service.", de: "Der Request wird im verbundenen Dienst zusammengeführt." },
|
||||
"confirm.review.mergeAction": { en: "Merge", de: "Zusammenführen" },
|
||||
"confirm.review.closeTitle": { en: "Close request #{number}?", de: "Request #{number} schließen?" },
|
||||
"confirm.review.closeMessage": { en: "The request is closed without being merged.", de: "Der Request wird geschlossen, ohne zusammengeführt zu werden." },
|
||||
"confirm.review.closeAction": { en: "Close request", de: "Request schließen" },
|
||||
"confirm.review.reopenTitle": { en: "Reopen request #{number}?", de: "Request #{number} wieder öffnen?" },
|
||||
"confirm.review.reopenMessage": { en: "The request is reopened in the connected service.", de: "Der Request wird im verbundenen Dienst wieder geöffnet." },
|
||||
"confirm.review.reopenAction": { en: "Reopen request", de: "Request wieder öffnen" },
|
||||
|
||||
// ── Tags panel ─────────────────────────────────────────────────────────────
|
||||
"tags.title": { en: "Tags", de: "Tags" },
|
||||
"tags.create": { en: "Create new tag", de: "Neues Tag erstellen" },
|
||||
"tags.expand": { en: "Expand tags", de: "Tags ausklappen" },
|
||||
"tags.collapse": { en: "Collapse tags", de: "Tags einklappen" },
|
||||
"tags.openRepo": { en: "Open a repository to list tags.", de: "Öffne ein Repository, um Tags zu sehen." },
|
||||
"tags.nameLabel": { en: "New tag name", de: "Name des neuen Tags" },
|
||||
"tags.messagePlaceholder": { en: "Message (optional)", de: "Nachricht (optional)" },
|
||||
"tags.messageLabel": { en: "Tag message", de: "Tag-Nachricht" },
|
||||
"tags.createAction": { en: "Create tag", de: "Tag erstellen" },
|
||||
"tags.empty": { en: "No tags.", de: "Keine Tags." },
|
||||
"tags.actionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
|
||||
"tags.push": { en: "Push to remote", de: "Zum Remote pushen" },
|
||||
"tags.deleteLocal": { en: "Delete local tag", de: "Lokales Tag löschen" },
|
||||
|
||||
// ── Branch panel ───────────────────────────────────────────────────────────
|
||||
"branches.title": { en: "Branches", de: "Branches" },
|
||||
"branches.create": { en: "Create new branch", de: "Neuen Branch erstellen" },
|
||||
"branches.expand": { en: "Expand branches", de: "Branches ausklappen" },
|
||||
"branches.collapse": { en: "Collapse branches", de: "Branches einklappen" },
|
||||
"branches.expandPanel": { en: "Expand branches panel", de: "Branch-Bereich ausklappen" },
|
||||
"branches.collapsePanel": { en: "Collapse branches panel", de: "Branch-Bereich einklappen" },
|
||||
"branches.openRepo": { en: "Open a repository to list branches.", de: "Öffne ein Repository, um Branches zu sehen." },
|
||||
"branches.namePlaceholder": { en: "new-branch-name", de: "neuer-branch-name" },
|
||||
"branches.nameLabel": { en: "New branch name", de: "Name des neuen Branches" },
|
||||
"branches.createAction": { en: "Create branch", de: "Branch erstellen" },
|
||||
"branches.filter": { en: "Filter branches", de: "Branches filtern" },
|
||||
"branches.filterClear": { en: "Clear filter", de: "Filter zurücksetzen" },
|
||||
"branches.revealCurrent": { en: "Reveal current branch in list", de: "Aktuellen Branch in der Liste zeigen" },
|
||||
"branches.notPublished": { en: "Not published", de: "Nicht veröffentlicht" },
|
||||
"branches.upstreamGone": { en: "{upstream} (gone)", de: "{upstream} (fehlt)" },
|
||||
"branches.emptyLocal": { en: "No local branches.", de: "Keine lokalen Branches." },
|
||||
"branches.emptyRemote": { en: "No remote branches.", de: "Keine Remote-Branches." },
|
||||
"branches.noMatch": { en: "No branches match “{query}”.", de: "Keine Branches passen zu „{query}“." },
|
||||
"branches.folderTitle": { en: "{name} · {count} branches", de: "{name} · {count} Branches" },
|
||||
"branches.folderTitleOne": { en: "{name} · 1 branch", de: "{name} · 1 Branch" },
|
||||
"branches.containsCurrent": { en: "Contains current branch", de: "Enthält den aktuellen Branch" },
|
||||
"branches.actions": { en: "Branch actions", de: "Branch-Aktionen" },
|
||||
"branches.actionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
|
||||
"branches.folderActionsFor": { en: "Actions for branch folder {name}", de: "Aktionen für Branch-Ordner {name}" },
|
||||
"branches.tipCurrent": { en: "Current branch (HEAD)", de: "Aktueller Branch (HEAD)" },
|
||||
"branches.tipTracks": { en: "Tracks {upstream}", de: "Verfolgt {upstream}" },
|
||||
"branches.tipGone": { en: "Upstream {upstream} is gone", de: "Upstream {upstream} existiert nicht mehr" },
|
||||
"branches.tipLocalOnly": { en: "Local only – not published", de: "Nur lokal – nicht veröffentlicht" },
|
||||
"branches.tipCheckedOut": { en: "Checked out locally as {name}", de: "Lokal ausgecheckt als {name}" },
|
||||
"branches.tipDoubleClick": { en: "Double-click to checkout", de: "Doppelklick zum Auschecken" },
|
||||
"branches.labelTracks": { en: "Tracks {upstream}", de: "Verfolgt {upstream}" },
|
||||
"branches.labelGone": { en: "Upstream gone", de: "Upstream fehlt" },
|
||||
"branches.labelLocalOnly": { en: "Local only", de: "Nur lokal" },
|
||||
"branches.labelCheckedOut": { en: "Checked out as {name}", de: "Ausgecheckt als {name}" },
|
||||
"branches.menuCompare": { en: "Compare with...", de: "Vergleichen mit …" },
|
||||
"branches.menuMerge": { en: "Merge into current", de: "In aktuellen Branch mergen" },
|
||||
"branches.menuRebase": { en: "Rebase current onto this", de: "Aktuellen Branch hierauf rebasen" },
|
||||
"branches.menuWorktree": { en: "Open in new worktree", de: "In neuem Worktree öffnen" },
|
||||
"branches.menuRenameRemote": { en: "Rename remote...", de: "Remote umbenennen …" },
|
||||
"branches.menuDeleteRemote": { en: "Delete remote", de: "Remote löschen" },
|
||||
"branches.cannotDeleteCurrent": { en: "Current branch cannot be deleted", de: "Der aktuelle Branch kann nicht gelöscht werden" },
|
||||
"branches.deleteRemoteBranch": { en: "Delete remote branch", de: "Remote-Branch löschen" },
|
||||
"branches.deleteLocalBranch": { en: "Delete local branch", de: "Lokalen Branch löschen" },
|
||||
"branches.deleteFolder": { en: "Delete {count} branches", de: "{count} Branches löschen" },
|
||||
"branches.folderKeepsCurrent": { en: "The current branch will be kept", de: "Der aktuelle Branch bleibt erhalten" },
|
||||
"branches.folderDeleteHint": { en: "Delete all branches in this folder", de: "Alle Branches in diesem Ordner löschen" },
|
||||
|
||||
// ── Worktree panel ─────────────────────────────────────────────────────────
|
||||
"worktrees.title": { en: "Worktrees", de: "Worktrees" },
|
||||
"worktrees.manage": { en: "Create or manage worktrees", de: "Worktrees erstellen oder verwalten" },
|
||||
"worktrees.expand": { en: "Expand worktrees", de: "Worktrees ausklappen" },
|
||||
"worktrees.collapse": { en: "Collapse worktrees", de: "Worktrees einklappen" },
|
||||
"worktrees.openRepo": { en: "Open a repository to list worktrees.", de: "Öffne ein Repository, um Worktrees zu sehen." },
|
||||
"worktrees.retry": { en: "Retry", de: "Erneut versuchen" },
|
||||
"worktrees.loading": { en: "Loading worktrees…", de: "Worktrees werden geladen…" },
|
||||
"worktrees.empty": { en: "No linked worktrees.", de: "Keine verknüpften Worktrees." },
|
||||
"worktrees.missingSuffix": { en: " — missing", de: " — fehlt" },
|
||||
"worktrees.bareSuffix": { en: " — bare repository", de: " — Bare-Repository" },
|
||||
"worktrees.bare": { en: "Bare repository", de: "Bare-Repository" },
|
||||
"worktrees.detached": { en: "Detached · {head}", de: "Losgelöst · {head}" },
|
||||
"worktrees.locked": { en: "Locked", de: "Gesperrt" },
|
||||
"worktrees.missing": { en: "Missing", de: "Fehlt" },
|
||||
"worktrees.current": { en: "Current", de: "Aktuell" },
|
||||
|
||||
// ── Stash panel ────────────────────────────────────────────────────────────
|
||||
"stashes.title": { en: "Stashes", de: "Stashes" },
|
||||
"stashes.create": { en: "Create stash", de: "Stash erstellen" },
|
||||
"stashes.expand": { en: "Expand stash panel", de: "Stash-Bereich ausklappen" },
|
||||
"stashes.collapse": { en: "Collapse stash panel", de: "Stash-Bereich einklappen" },
|
||||
"stashes.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
|
||||
"stashes.messagePlaceholder": { en: "Optional message", de: "Nachricht (optional)" },
|
||||
"stashes.messageLabel": { en: "Stash message", de: "Stash-Nachricht" },
|
||||
"stashes.untracked": { en: "Untracked", de: "Unverfolgte" },
|
||||
"stashes.saveHint": { en: "Save current working tree changes to a stash", de: "Aktuelle Änderungen im Arbeitsverzeichnis in einem Stash sichern" },
|
||||
"stashes.save": { en: "Stash", de: "Stashen" },
|
||||
"stashes.empty": { en: "No stashes saved.", de: "Keine Stashes gespeichert." },
|
||||
"stashes.on": { en: "on {branch}", de: "auf {branch}" },
|
||||
"stashes.applyHint": { en: "Apply stash and keep it", de: "Stash anwenden und behalten" },
|
||||
"stashes.apply": { en: "Apply", de: "Anwenden" },
|
||||
"stashes.popHint": { en: "Apply stash and remove it if successful", de: "Stash anwenden und bei Erfolg entfernen" },
|
||||
"stashes.pop": { en: "Pop", de: "Pop" },
|
||||
"stashes.dropHint": { en: "Delete stash", de: "Stash löschen" },
|
||||
"stashes.drop": { en: "Drop", de: "Löschen" },
|
||||
|
||||
// ── Status panel ───────────────────────────────────────────────────────────
|
||||
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
|
||||
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
|
||||
"status.title": { en: "Changes", de: "Änderungen" },
|
||||
"status.viewGroup": { en: "Changes view", de: "Ansicht der Änderungen" },
|
||||
"status.viewList": { en: "List view", de: "Listenansicht" },
|
||||
"status.viewListShort": { en: "List", de: "Liste" },
|
||||
"status.viewTree": { en: "Tree view", de: "Baumansicht" },
|
||||
"status.viewTreeShort": { en: "Tree", de: "Baum" },
|
||||
"status.staged": { en: "{count} staged", de: "{count} gestaged" },
|
||||
"status.unstaged": { en: "{count} unstaged", de: "{count} ungestaged" },
|
||||
"status.discardAllHint": { en: "Discard all staged and unstaged changes", de: "Alle gestagten und ungestagten Änderungen verwerfen" },
|
||||
"status.discardAll": { en: "Discard all", de: "Alles verwerfen" },
|
||||
"status.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
|
||||
"status.clean": { en: "Working tree is clean.", de: "Das Arbeitsverzeichnis ist sauber." },
|
||||
"status.noChanges": { en: "No file changes returned.", de: "Keine Dateiänderungen zurückgegeben." },
|
||||
"status.laneUnstaged": { en: "Unstaged changes", de: "Ungestagte Änderungen" },
|
||||
"status.laneStaged": { en: "Staged changes", de: "Gestagte Änderungen" },
|
||||
"status.unstagedTitle": { en: "Unstaged", de: "Ungestaged" },
|
||||
"status.stagedTitle": { en: "Staged", de: "Gestaged" },
|
||||
"status.workingTree": { en: "Working tree", de: "Arbeitsverzeichnis" },
|
||||
"status.nextCommit": { en: "Next commit", de: "Nächster Commit" },
|
||||
"status.stageSelected": { en: "Stage {count} selected files", de: "{count} ausgewählte Dateien stagen" },
|
||||
"status.unstageSelected": { en: "Unstage {count} selected files", de: "{count} ausgewählte Dateien entstagen" },
|
||||
"status.stageAllHint": { en: "Stage all unstaged files", de: "Alle ungestagten Dateien stagen" },
|
||||
"status.stageAll": { en: "Stage all", de: "Alle stagen" },
|
||||
"status.unstageAllHint": { en: "Unstage all staged files", de: "Alle gestagten Dateien entstagen" },
|
||||
"status.unstageAll": { en: "Unstage all", de: "Alle entstagen" },
|
||||
"status.discardUnstagedSelected": { en: "Discard unstaged changes in {count} selected files", de: "Ungestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
|
||||
"status.discardStagedSelected": { en: "Discard staged changes in {count} selected files", de: "Gestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
|
||||
"status.discard": { en: "Discard", de: "Verwerfen" },
|
||||
"status.selectInExplorer": { en: "Select {path} in Explorer", de: "{path} im Explorer auswählen" },
|
||||
"status.stageFile": { en: "Stage file", de: "Datei stagen" },
|
||||
"status.unstageFile": { en: "Unstage file", de: "Datei entstagen" },
|
||||
"status.showUnstagedDetails": { en: "Show unstaged details", de: "Ungestagte Details anzeigen" },
|
||||
"status.showStagedDetails": { en: "Show staged details", de: "Gestagte Details anzeigen" },
|
||||
"status.discardUnstaged": { en: "Discard unstaged changes", de: "Ungestagte Änderungen verwerfen" },
|
||||
"status.discardStaged": { en: "Discard staged changes", de: "Gestagte Änderungen verwerfen" },
|
||||
"status.emptyUnstaged": { en: "No unstaged changes.", de: "Keine ungestagten Änderungen." },
|
||||
"status.emptyStaged": { en: "Stage files to include them in the next commit.", de: "Stage Dateien, damit sie in den nächsten Commit kommen." },
|
||||
"status.working": { en: "Working", de: "Arbeitet" },
|
||||
"status.repositoryRoot": { en: "Repository root", de: "Repository-Wurzel" },
|
||||
"status.menuActionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
|
||||
"status.menuKindUnstagedFile": { en: "Unstaged file", de: "Ungestagte Datei" },
|
||||
"status.menuKindUnstagedFolder": { en: "Unstaged folder", de: "Ungestagter Ordner" },
|
||||
"status.menuKindStagedFile": { en: "Staged file", de: "Gestagte Datei" },
|
||||
"status.menuKindStagedFolder": { en: "Staged folder", de: "Gestagter Ordner" },
|
||||
"status.menuFileCount": { en: "{count} files", de: "{count} Dateien" },
|
||||
"status.menuFileCountOne": { en: "1 file", de: "1 Datei" },
|
||||
"status.menuStageFile": { en: "Stage file", de: "Datei stagen" },
|
||||
"status.menuStageFolder": { en: "Stage folder", de: "Ordner stagen" },
|
||||
"status.menuUnstageFile": { en: "Unstage file", de: "Datei entstagen" },
|
||||
"status.menuUnstageFolder": { en: "Unstage folder", de: "Ordner entstagen" },
|
||||
"status.menuStageHint": { en: "Add to the next commit", de: "Zum nächsten Commit hinzufügen" },
|
||||
"status.menuUnstageHint": { en: "Move back to working changes", de: "Zurück zu den Arbeitsänderungen" },
|
||||
"status.menuStashFile": { en: "Stash file", de: "Datei stashen" },
|
||||
"status.menuStashFolder": { en: "Stash folder", de: "Ordner stashen" },
|
||||
"status.menuStashHintOne": { en: "Save this file for later", de: "Diese Datei für später sichern" },
|
||||
"status.menuStashHint": { en: "Save {count} files for later", de: "{count} Dateien für später sichern" },
|
||||
"status.menuStopTrackingHint": { en: "Keep the working-tree content and remove it from the Git index", de: "Inhalt im Arbeitsverzeichnis behalten und aus dem Git-Index entfernen" },
|
||||
"status.menuStopTrackingFile": { en: "Stop tracking file", de: "Datei nicht mehr verfolgen" },
|
||||
"status.menuStopTrackingFolder": { en: "Stop tracking folder", de: "Ordner nicht mehr verfolgen" },
|
||||
"status.menuStopTrackingNote": { en: "Keep it on disk and remove it from Git", de: "Auf der Festplatte behalten und aus Git entfernen" },
|
||||
"status.menuIgnoreFileHint": { en: "Add /{path} to .gitignore", de: "/{path} zu .gitignore hinzufügen" },
|
||||
"status.menuIgnoreFile": { en: "Ignore file", de: "Datei ignorieren" },
|
||||
"status.menuIgnoreFileNote": { en: "Add only this file to .gitignore", de: "Nur diese Datei zu .gitignore hinzufügen" },
|
||||
"status.menuIgnoreExtHint": { en: "Add *.{ext} to .gitignore", de: "*.{ext} zu .gitignore hinzufügen" },
|
||||
"status.menuIgnoreExt": { en: "Ignore all *.{ext} files", de: "Alle *.{ext}-Dateien ignorieren" },
|
||||
"status.menuIgnoreExtNote": { en: "Match this file type repository-wide", de: "Diesen Dateityp im ganzen Repository erfassen" },
|
||||
"status.menuIgnoreFolderHint": { en: "Add /{path}/ to .gitignore", de: "/{path}/ zu .gitignore hinzufügen" },
|
||||
"status.menuIgnoreFolder": { en: "Ignore folder", de: "Ordner ignorieren" },
|
||||
"status.menuIgnoreFolderNote": { en: "Add this folder and its contents to .gitignore", de: "Diesen Ordner samt Inhalt zu .gitignore hinzufügen" },
|
||||
|
||||
// ── History panel ──────────────────────────────────────────────────────────
|
||||
"history.panelLabel": { en: "Commit history", de: "Commit-Historie" },
|
||||
"history.eyebrow": { en: "History", de: "Historie" },
|
||||
"history.title": { en: "Commits", de: "Commits" },
|
||||
"history.customizeBranches": { en: "Customize visible branches", de: "Sichtbare Branches anpassen" },
|
||||
"history.visibleBranches": { en: "{visible} of {total} branches visible. Customize branches.", de: "{visible} von {total} Branches sichtbar. Branches anpassen." },
|
||||
"history.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
|
||||
"history.noCommits": { en: "No commits returned.", de: "Keine Commits zurückgegeben." },
|
||||
"history.noMatchingCommits": { en: "No loaded commits match the selected branches.", de: "Keine geladenen Commits passen zu den gewählten Branches." },
|
||||
"history.refs": { en: "Commit references", de: "Commit-Referenzen" },
|
||||
"history.localOnlyBadge": { en: "LOCAL", de: "LOKAL" },
|
||||
"history.localOnlyHint": { en: "This branch exists only locally and has not been published yet", de: "Dieser Branch existiert nur lokal und wurde noch nicht veröffentlicht" },
|
||||
"history.refsOnCommit": { en: "References on this commit", de: "Referenzen auf diesem Commit" },
|
||||
"history.current": { en: "Current", de: "Aktuell" },
|
||||
"history.localOnly": { en: "Local only", de: "Nur lokal" },
|
||||
"history.tags": { en: "Tags", de: "Tags" },
|
||||
"history.other": { en: "Other", de: "Sonstige" },
|
||||
"history.note": { en: "Note", de: "Notiz" },
|
||||
"history.gitNote": { en: "Git Note", de: "Git-Notiz" },
|
||||
"history.clickToOpen": { en: "Click to open", de: "Zum Öffnen klicken" },
|
||||
"history.noteEmpty": { en: "This Git note is empty.", de: "Diese Git-Notiz ist leer." },
|
||||
"history.openNote": { en: "Open Git note for {hash}", de: "Git-Notiz zu {hash} öffnen" },
|
||||
"history.addNote": { en: "Add a Git note to {hash}", de: "Git-Notiz zu {hash} hinzufügen" },
|
||||
"history.tagTitle": { en: "Tag {name}", de: "Tag {name}" },
|
||||
"history.showMoreRefs": { en: "Show {count} more references", de: "{count} weitere Referenzen anzeigen" },
|
||||
"history.showMoreRefsOne": { en: "Show 1 more reference", de: "1 weitere Referenz anzeigen" },
|
||||
"history.changedFiles": { en: "Changed files", de: "Geänderte Dateien" },
|
||||
"history.diffBeforeRestore": { en: "Show differences before restoring - {file}", de: "Unterschiede vor dem Wiederherstellen anzeigen – {file}" },
|
||||
"history.commitActions": { en: "Commit actions", de: "Commit-Aktionen" },
|
||||
"history.actionsFor": { en: "Actions for {hash}", de: "Aktionen für {hash}" },
|
||||
"history.loadingOlder": { en: "Loading older commits…", de: "Ältere Commits werden geladen…" },
|
||||
"history.loadOlderFailed": { en: "Older commits could not be loaded.", de: "Ältere Commits konnten nicht geladen werden." },
|
||||
"history.retry": { en: "Retry", de: "Erneut versuchen" },
|
||||
"history.loadOlder": { en: "Load older commits", de: "Ältere Commits laden" },
|
||||
"history.menuBranch": { en: "Branch", de: "Branch" },
|
||||
"history.menuRestore": { en: "Restore", de: "Wiederherstellen" },
|
||||
"history.menuCherryPick": { en: "Cherry-pick", de: "Cherry-Pick" },
|
||||
"history.menuCherryPickHint": { en: "Apply this commit's changes on top of the current branch", de: "Änderungen dieses Commits auf den aktuellen Branch anwenden" },
|
||||
"history.menuRevert": { en: "Revert", de: "Rückgängig machen" },
|
||||
"history.menuRevertHint": { en: "Create a new commit that reverses this commit", de: "Neuen Commit erstellen, der diesen Commit zurücknimmt" },
|
||||
"history.branchDialogLabel": { en: "Select visible branches", de: "Sichtbare Branches wählen" },
|
||||
"history.graphEyebrow": { en: "Git graph", de: "Git-Graph" },
|
||||
"history.graphTitle": { en: "Visible branches", de: "Sichtbare Branches" },
|
||||
"history.closeBranchDialog": { en: "Close branch selection", de: "Branch-Auswahl schließen" },
|
||||
"history.focus": { en: "Focus", de: "Fokus" },
|
||||
"history.showAll": { en: "Show all", de: "Alle zeigen" },
|
||||
"history.hideAll": { en: "Hide all", de: "Alle verbergen" },
|
||||
"history.branchLocalOnlyTitle": { en: "{name} · Local only — not published yet", de: "{name} · Nur lokal — noch nicht veröffentlicht" },
|
||||
"history.branchTracksTitle": { en: "{name} · Tracks {upstream}", de: "{name} · Verfolgt {upstream}" },
|
||||
"history.branchRemoteTitle": { en: "Remote branch {name}", de: "Remote-Branch {name}" },
|
||||
"history.branchLocalTitle": { en: "Local branch {name}", de: "Lokaler Branch {name}" },
|
||||
"history.branchesSelected": { en: "{visible} of {total} branches selected", de: "{visible} von {total} Branches ausgewählt" },
|
||||
|
||||
// ── Reflog dialog ──────────────────────────────────────────────────────────
|
||||
"reflog.title": { en: "Reflog", de: "Reflog" },
|
||||
"reflog.eyebrow": { en: "Recovery history", de: "Wiederherstellungs-Historie" },
|
||||
"reflog.searchPlaceholder": { en: "Search actions, hashes or authors", de: "Aktionen, Hashes oder Autoren suchen" },
|
||||
"reflog.searchLabel": { en: "Search reflog", de: "Reflog durchsuchen" },
|
||||
"reflog.loading": { en: "Loading reflog…", de: "Reflog wird geladen…" },
|
||||
"reflog.noMatch": { en: "No reflog entries match this search.", de: "Keine Reflog-Einträge passen zu dieser Suche." },
|
||||
"reflog.listLabel": { en: "Reflog entries", de: "Reflog-Einträge" },
|
||||
"reflog.author": { en: "Author", de: "Autor" },
|
||||
"reflog.date": { en: "Date", de: "Datum" },
|
||||
"reflog.preview": { en: "Preview changes to current HEAD", de: "Unterschiede zum aktuellen HEAD ansehen" },
|
||||
"reflog.safeRecovery": { en: "Safe recovery", de: "Sichere Wiederherstellung" },
|
||||
"reflog.safeRecoveryNote": { en: "Create a new branch here. The current branch is not reset or deleted.", de: "Hier einen neuen Branch erstellen. Der aktuelle Branch wird weder zurückgesetzt noch gelöscht." },
|
||||
"reflog.recoveryBranch": { en: "Recovery branch", de: "Wiederherstellungs-Branch" },
|
||||
"reflog.createBranch": { en: "Create and checkout recovery branch", de: "Wiederherstellungs-Branch erstellen und auschecken" },
|
||||
"reflog.selectEntry": { en: "Select a reflog entry to inspect or recover it.", de: "Wähle einen Reflog-Eintrag, um ihn anzusehen oder wiederherzustellen." },
|
||||
|
||||
// ── Blame dialog ───────────────────────────────────────────────────────────
|
||||
"blame.dialogLabel": { en: "File blame", de: "Datei-Blame" },
|
||||
"blame.eyebrow": { en: "Blame", de: "Blame" },
|
||||
"blame.loading": { en: "Loading blame…", de: "Blame wird geladen…" },
|
||||
"blame.empty": { en: "No blame information available for this file.", de: "Für diese Datei gibt es keine Blame-Informationen." },
|
||||
"blame.searchPlaceholder": { en: "Search blame", de: "Blame durchsuchen" },
|
||||
"blame.searchClear": { en: "Clear blame search", de: "Blame-Suche zurücksetzen" },
|
||||
"blame.columnCommit": { en: "Commit", de: "Commit" },
|
||||
"blame.columnCode": { en: "Code", de: "Code" },
|
||||
"blame.noMatches": { en: "No matches found.", de: "Keine Treffer gefunden." },
|
||||
"blame.uncommitted": { en: "Not committed yet", de: "Noch nicht committet" },
|
||||
|
||||
// ── Interactive rebase dialog ──────────────────────────────────────────────
|
||||
"rebase.dialogLabel": { en: "Interactive rebase", de: "Interaktiver Rebase" },
|
||||
"rebase.eyebrow": { en: "Rewrite local history", de: "Lokale Historie umschreiben" },
|
||||
"rebase.rebaseOnto": { en: "Rebase", de: "Rebase" },
|
||||
"rebase.currentBranch": { en: "current branch", de: "aktueller Branch" },
|
||||
"rebase.onto": { en: "onto", de: "auf" },
|
||||
"rebase.baseRemote": { en: "Remote - {name}", de: "Remote – {name}" },
|
||||
"rebase.baseLocal": { en: "Local - {name}", de: "Lokal – {name}" },
|
||||
"rebase.selectBase": { en: "Select a base branch", de: "Basis-Branch wählen" },
|
||||
"rebase.hint": { en: "Oldest commit first. Reorder commits, then choose how each one should be replayed.", de: "Ältester Commit zuerst. Ordne die Commits neu und wähle, wie jeder wiederholt werden soll." },
|
||||
"rebase.loading": { en: "Loading rebase range…", de: "Rebase-Bereich wird geladen…" },
|
||||
"rebase.selectBaseHint": { en: "Select the branch or commit that should become the new base.", de: "Wähle den Branch oder Commit, der die neue Basis werden soll." },
|
||||
"rebase.noCommits": { en: "No linear commits are available above this base.", de: "Über dieser Basis gibt es keine linearen Commits." },
|
||||
"rebase.planLabel": { en: "Interactive rebase plan", de: "Plan für den interaktiven Rebase" },
|
||||
"rebase.moveUp": { en: "Move up", de: "Nach oben" },
|
||||
"rebase.moveDown": { en: "Move down", de: "Nach unten" },
|
||||
"rebase.actionFor": { en: "Action for {hash}", de: "Aktion für {hash}" },
|
||||
"rebase.newMessageFor": { en: "New message for {hash}", de: "Neue Nachricht für {hash}" },
|
||||
"rebase.invalidSquash": { en: "Squash and fixup need an earlier commit that is not dropped.", de: "Squash und Fixup brauchen einen früheren Commit, der nicht verworfen wird." },
|
||||
"rebase.invalidReword": { en: "Reword messages cannot be empty.", de: "Neue Commit-Nachrichten dürfen nicht leer sein." },
|
||||
"rebase.keptCount": { en: "{kept} of {total} commits kept", de: "{kept} von {total} Commits behalten" },
|
||||
"rebase.start": { en: "Start rebase", de: "Rebase starten" },
|
||||
|
||||
// ── Worktree dialog ────────────────────────────────────────────────────────
|
||||
"worktreeDialog.eyebrow": { en: "Parallel workspaces", de: "Parallele Arbeitsbereiche" },
|
||||
"worktreeDialog.refresh": { en: "Refresh worktrees", de: "Worktrees neu laden" },
|
||||
"worktreeDialog.linked": { en: "linked worktrees", de: "verknüpfte Worktrees" },
|
||||
"worktreeDialog.withChanges": { en: "with changes", de: "mit Änderungen" },
|
||||
"worktreeDialog.stale": { en: "stale entries", de: "veraltete Einträge" },
|
||||
"worktreeDialog.new": { en: "New worktree", de: "Neuer Worktree" },
|
||||
"worktreeDialog.createEyebrow": { en: "Create", de: "Erstellen" },
|
||||
"worktreeDialog.createTitle": { en: "Choose what this workspace should track", de: "Wähle, was dieser Arbeitsbereich verfolgen soll" },
|
||||
"worktreeDialog.closeCreate": { en: "Close create form", de: "Erstellen-Formular schließen" },
|
||||
"worktreeDialog.typeLabel": { en: "Worktree type", de: "Worktree-Typ" },
|
||||
"worktreeDialog.existingBranch": { en: "Existing branch", de: "Vorhandener Branch" },
|
||||
"worktreeDialog.newBranch": { en: "New branch", de: "Neuer Branch" },
|
||||
"worktreeDialog.detached": { en: "Detached", de: "Losgelöst" },
|
||||
"worktreeDialog.selectBranch": { en: "Select a local branch", de: "Lokalen Branch wählen" },
|
||||
"worktreeDialog.newBranchName": { en: "New branch name", de: "Name des neuen Branches" },
|
||||
"worktreeDialog.newBranchPlaceholder": { en: "feature/my-change", de: "feature/meine-aenderung" },
|
||||
"worktreeDialog.startPoint": { en: "Start point", de: "Startpunkt" },
|
||||
"worktreeDialog.startPointPlaceholder": { en: "HEAD, branch or commit", de: "HEAD, Branch oder Commit" },
|
||||
"worktreeDialog.commitOrRef": { en: "Commit or ref", de: "Commit oder Ref" },
|
||||
"worktreeDialog.folder": { en: "Folder", de: "Ordner" },
|
||||
"worktreeDialog.folderPlaceholder": { en: "Choose an empty folder", de: "Leeren Ordner wählen" },
|
||||
"worktreeDialog.browse": { en: "Browse", de: "Durchsuchen" },
|
||||
"worktreeDialog.lockAfterCreate": { en: "Lock after creation", de: "Nach dem Erstellen sperren" },
|
||||
"worktreeDialog.lockAfterCreateNote": { en: "Protects removable or temporary locations from pruning.", de: "Schützt Wechseldatenträger oder temporäre Orte vor dem Aufräumen." },
|
||||
"worktreeDialog.createAction": { en: "Create worktree", de: "Worktree erstellen" },
|
||||
"worktreeDialog.loading": { en: "Reading worktrees…", de: "Worktrees werden gelesen…" },
|
||||
"worktreeDialog.emptyTitle": { en: "No worktrees found", de: "Keine Worktrees gefunden" },
|
||||
"worktreeDialog.emptyNote": { en: "Create one to work on another branch without switching this workspace.", de: "Erstelle einen, um an einem anderen Branch zu arbeiten, ohne diesen Arbeitsbereich zu wechseln." },
|
||||
"worktreeDialog.main": { en: "Main", de: "Haupt" },
|
||||
"worktreeDialog.open": { en: "Open", de: "Offen" },
|
||||
"worktreeDialog.locked": { en: "Locked", de: "Gesperrt" },
|
||||
"worktreeDialog.staleBadge": { en: "Stale", de: "Veraltet" },
|
||||
"worktreeDialog.refreshTab": { en: "Refresh tab", de: "Tab aktualisieren" },
|
||||
"worktreeDialog.openTab": { en: "Open tab", de: "Tab öffnen" },
|
||||
"worktreeDialog.repairHint": { en: "Locate and repair worktree", de: "Worktree finden und reparieren" },
|
||||
"worktreeDialog.repair": { en: "Repair", de: "Reparieren" },
|
||||
"worktreeDialog.moveHint": { en: "Move worktree", de: "Worktree verschieben" },
|
||||
"worktreeDialog.move": { en: "Move", de: "Verschieben" },
|
||||
"worktreeDialog.unlockHint": { en: "Unlock worktree", de: "Worktree entsperren" },
|
||||
"worktreeDialog.unlock": { en: "Unlock", de: "Entsperren" },
|
||||
"worktreeDialog.lockHint": { en: "Lock worktree", de: "Worktree sperren" },
|
||||
"worktreeDialog.lock": { en: "Lock", de: "Sperren" },
|
||||
"worktreeDialog.removeHint": { en: "Remove worktree", de: "Worktree entfernen" },
|
||||
"worktreeDialog.removePruneHint": { en: "Use Prune to remove stale metadata", de: "Nutze Prune, um veraltete Metadaten zu entfernen" },
|
||||
"worktreeDialog.remove": { en: "Remove", de: "Entfernen" },
|
||||
"worktreeDialog.protectedNote": { en: "Dirty, active and locked worktrees are protected.", de: "Worktrees mit Änderungen, aktive und gesperrte sind geschützt." },
|
||||
"worktreeDialog.removeEyebrow": { en: "Remove worktree", de: "Worktree entfernen" },
|
||||
"worktreeDialog.removeTitle": { en: "Remove {name}?", de: "{name} entfernen?" },
|
||||
"worktreeDialog.cancelRemoval": { en: "Cancel removal", de: "Entfernen abbrechen" },
|
||||
"worktreeDialog.removeBody": { en: "This removes the worktree folder and its Git registration. The branch itself is kept.", de: "Das entfernt den Worktree-Ordner und seine Git-Registrierung. Der Branch selbst bleibt erhalten." },
|
||||
"worktreeDialog.removeForce": { en: "Remove despite local changes", de: "Trotz lokaler Änderungen entfernen" },
|
||||
"worktreeDialog.removeForceNote": { en: "{count} changed files may be permanently deleted.", de: "{count} geänderte Dateien können dauerhaft gelöscht werden." },
|
||||
"worktreeDialog.lockEyebrow": { en: "Protect worktree", de: "Worktree schützen" },
|
||||
"worktreeDialog.lockTitle": { en: "Lock {name}", de: "{name} sperren" },
|
||||
"worktreeDialog.cancelLock": { en: "Cancel locking", de: "Sperren abbrechen" },
|
||||
"worktreeDialog.reason": { en: "Reason", de: "Grund" },
|
||||
"worktreeDialog.optional": { en: "optional", de: "optional" },
|
||||
"worktreeDialog.reasonPlaceholder": { en: "External drive, long-running work…", de: "Externe Festplatte, langlaufende Arbeit…" },
|
||||
"worktreeDialog.detachedAt": { en: "Detached at {head}", de: "Losgelöst bei {head}" },
|
||||
"worktreeDialog.bare": { en: "Bare worktree", de: "Bare-Worktree" },
|
||||
"worktreeDialog.chooseNewLocation": { en: "Choose new worktree location", de: "Neuen Ort für den Worktree wählen" },
|
||||
"worktreeDialog.chooseFolder": { en: "Choose worktree folder", de: "Worktree-Ordner wählen" },
|
||||
|
||||
// ── AI settings ────────────────────────────────────────────────────────────
|
||||
"ai.providerLabel": { en: "AI provider", de: "KI-Anbieter" },
|
||||
"ai.custom": { en: "Custom endpoint", de: "Eigener Endpunkt" },
|
||||
"ai.model": { en: "Model", de: "Modell" },
|
||||
"ai.apiKey": { en: "API key", de: "API-Schlüssel" },
|
||||
"ai.apiKeyOptional": { en: "API key (optional)", de: "API-Schlüssel (optional)" },
|
||||
"ai.endpointUrl": { en: "Endpoint URL", de: "Endpunkt-URL" },
|
||||
"ai.optional": { en: "Optional", de: "Optional" },
|
||||
"ai.customHint": { en: "For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.", de: "Für lokale, OpenAI-kompatible Server wie Ollama oder LM Studio. Die Basis-URL sollte auf /v1 enden." },
|
||||
"ai.keysNotLoaded": { en: "API keys could not be loaded. Existing credentials have been preserved.", de: "Die API-Schlüssel konnten nicht geladen werden. Vorhandene Zugangsdaten bleiben erhalten." },
|
||||
"ai.waitForSettings": { en: "Please wait for AI settings to load.", de: "Bitte warte, bis die KI-Einstellungen geladen sind." },
|
||||
"branches.listLabel": { en: "Branch list", de: "Branch-Liste" },
|
||||
"worktreeDialog.refreshShort": { en: "Refresh", de: "Aktualisieren" },
|
||||
"history.hoverBranches": { en: "Branches: {list}", de: "Branches: {list}" },
|
||||
"history.hoverContaining": { en: "Branches containing this commit: {list}", de: "Branches, die diesen Commit enthalten: {list}" },
|
||||
|
||||
// ── Branch delete confirmation ─────────────────────────────────────────────
|
||||
"confirm.branchDelete.eyebrow": { en: "Delete branch", de: "Branch löschen" },
|
||||
"confirm.branchDelete.eyebrowRemote": { en: "Remote branch", de: "Remote-Branch" },
|
||||
"confirm.branchDelete.eyebrowForce": { en: "Force delete", de: "Löschen erzwingen" },
|
||||
"confirm.branchDelete.title": { en: "Delete branch?", de: "Branch löschen?" },
|
||||
"confirm.branchDelete.titleRemote": { en: "Delete remote branch?", de: "Remote-Branch löschen?" },
|
||||
"confirm.branchDelete.titleForce": { en: "Force delete branch?", de: "Löschen des Branches erzwingen?" },
|
||||
"confirm.branchDelete.message": { en: "This branch will be removed from your local repository.", de: "Dieser Branch wird aus deinem lokalen Repository entfernt." },
|
||||
"confirm.branchDelete.messageRemote": { en: "This branch will be removed from the shared remote repository.", de: "Dieser Branch wird aus dem gemeinsamen Remote-Repository entfernt." },
|
||||
"confirm.branchDelete.messageForce": { en: "This branch is not fully merged. Some commits may only exist here.", de: "Dieser Branch ist nicht vollständig gemergt. Manche Commits gibt es vielleicht nur hier." },
|
||||
"confirm.branchDelete.note": { en: "Git will stop the deletion if the branch contains unmerged commits.", de: "Git bricht das Löschen ab, wenn der Branch nicht gemergte Commits enthält." },
|
||||
"confirm.branchDelete.noteRemote": { en: "This affects everyone using {remote}. Your local branch is kept.", de: "Das betrifft alle, die {remote} nutzen. Dein lokaler Branch bleibt erhalten." },
|
||||
"confirm.branchDelete.noteForce": { en: "Force deletion can make unmerged commits difficult to recover.", de: "Erzwungenes Löschen kann nicht gemergte Commits schwer wiederherstellbar machen." },
|
||||
"confirm.branchDelete.action": { en: "Delete", de: "Löschen" },
|
||||
"confirm.branchDelete.actionRemote": { en: "Delete from remote", de: "Auf dem Remote löschen" },
|
||||
"confirm.branchDelete.actionForce": { en: "Force delete", de: "Löschen erzwingen" },
|
||||
|
||||
// ── Discard confirmation ───────────────────────────────────────────────────
|
||||
"confirm.discard.eyebrow": { en: "Confirm discard", de: "Verwerfen bestätigen" },
|
||||
"confirm.discard.titleHunk": { en: "Discard hunk?", de: "Block verwerfen?" },
|
||||
"confirm.discard.titleLines": { en: "Discard selected lines?", de: "Ausgewählte Zeilen verwerfen?" },
|
||||
"confirm.discard.titleFiles": { en: "Discard changes in {count} files?", de: "Änderungen in {count} Dateien verwerfen?" },
|
||||
"confirm.discard.titleFile": { en: "Discard file changes?", de: "Dateiänderungen verwerfen?" },
|
||||
"confirm.discard.messageHunk": { en: "This resets the {source} for the selected hunk below.", de: "Das setzt {source} für den unten gewählten Block zurück." },
|
||||
"confirm.discard.messageLines": { en: "This resets the {source} for the selected lines below.", de: "Das setzt {source} für die unten gewählten Zeilen zurück." },
|
||||
"confirm.discard.messageFiles": { en: "This resets the {source} for the {count} files below.", de: "Das setzt {source} für die {count} Dateien unten zurück." },
|
||||
"confirm.discard.messageFile": { en: "This resets the {source} for the file below.", de: "Das setzt {source} für die Datei unten zurück." },
|
||||
"confirm.discard.sourceBoth": { en: "staged and unstaged changes", de: "gestagten und ungestagten Änderungen" },
|
||||
"confirm.discard.sourceStaged": { en: "staged changes", de: "gestagten Änderungen" },
|
||||
"confirm.discard.sourceUnstaged": { en: "unstaged changes", de: "ungestagten Änderungen" },
|
||||
"confirm.discard.note": { en: "This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.", de: "Das lässt sich nicht rückgängig machen. Existiert eine Datei nur im Arbeitsverzeichnis, kann sie ganz gelöscht werden." },
|
||||
"confirm.discard.action": { en: "Discard", de: "Verwerfen" },
|
||||
|
||||
// ── Worktree removal confirmation ──────────────────────────────────────────
|
||||
"confirm.worktreeRemove.title": { en: "Remove {name}?", de: "{name} entfernen?" },
|
||||
"confirm.worktreeRemove.message": { en: "This removes the worktree folder and its Git registration. The branch itself is kept.", de: "Das entfernt den Worktree-Ordner und seine Git-Registrierung. Der Branch selbst bleibt erhalten." },
|
||||
"confirm.worktreeRemove.action": { en: "Remove worktree", de: "Worktree entfernen" },
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof messages;
|
||||
export type MessageEntry = { en: string; de: string };
|
||||
Reference in New Issue
Block a user