Auth for submodule ops, revision checkout, central confirm dialog, unified sidebar sizing #46
+193
-33
@@ -7,6 +7,8 @@
|
|||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
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 { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||||
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
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 TitleBar from "./lib/TitleBar.svelte";
|
||||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||||
@@ -22,17 +24,16 @@
|
|||||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
|
||||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||||
import TagsPanel from "./lib/components/TagsPanel.svelte";
|
import TagsPanel from "./lib/components/TagsPanel.svelte";
|
||||||
import WorktreePanel from "./lib/components/WorktreePanel.svelte";
|
import WorktreePanel from "./lib/components/WorktreePanel.svelte";
|
||||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||||
|
import ConfirmDialog from "./lib/components/ConfirmDialog.svelte";
|
||||||
import CommandPalette from "./lib/components/CommandPalette.svelte";
|
import CommandPalette from "./lib/components/CommandPalette.svelte";
|
||||||
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
|
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
|
||||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||||
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
|
||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
||||||
@@ -427,6 +428,8 @@
|
|||||||
let externalToolsDetectionUnavailable = false;
|
let externalToolsDetectionUnavailable = false;
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
|
let confirmDialogRequest: ConfirmRequest | null = null;
|
||||||
|
let confirmDialogResolve: ((confirmed: boolean) => void) | null = null;
|
||||||
let compareFrom = "";
|
let compareFrom = "";
|
||||||
let compareTo = "";
|
let compareTo = "";
|
||||||
let comparison: GitCommitComparison | null = null;
|
let comparison: GitCommitComparison | null = null;
|
||||||
@@ -1283,6 +1286,81 @@
|
|||||||
function applyLanguagePreference(next: AppLanguage) {
|
function applyLanguagePreference(next: AppLanguage) {
|
||||||
document.documentElement.lang = next;
|
document.documentElement.lang = next;
|
||||||
document.documentElement.dataset.language = 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) {
|
function applyThemePreference(next: AppTheme) {
|
||||||
@@ -3281,9 +3359,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scope = remoteFolder ? "remote" : "local";
|
const notes = [
|
||||||
const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : "";
|
currentBranchKept ? t("confirm.branchFolder.noteCurrent") : "",
|
||||||
if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return;
|
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;
|
const repoPath = activeRepoPath;
|
||||||
if (remoteFolder) {
|
if (remoteFolder) {
|
||||||
@@ -3828,7 +3918,11 @@
|
|||||||
|
|
||||||
async function abortRebase() {
|
async function abortRebase() {
|
||||||
if (!activeRepoPath || !rebaseInProgress) return;
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
await runOperation("Aborting rebase", async () => {
|
await runOperation("Aborting rebase", async () => {
|
||||||
@@ -3995,7 +4089,11 @@
|
|||||||
|
|
||||||
async function deleteLocalTag(tag: GitTag) {
|
async function deleteLocalTag(tag: GitTag) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
await runOperation(`Deleting tag ${tag.name}`, async () => {
|
await runOperation(`Deleting tag ${tag.name}`, async () => {
|
||||||
@@ -4188,7 +4286,11 @@
|
|||||||
|
|
||||||
async function abortCherryPick() {
|
async function abortCherryPick() {
|
||||||
if (!activeRepoPath || !cherryPickInProgress) return;
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
await runOperation("Aborting cherry-pick", async () => {
|
await runOperation("Aborting cherry-pick", async () => {
|
||||||
@@ -4366,9 +4468,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
const confirmed = window.confirm(appLanguage === "de"
|
const confirmed = await requestConfirmation({
|
||||||
? "Das lokale und das entfernte Repository besitzen getrennte Commit-Historien.\n\nTrotzdem zusammenführen? Dabei können Merge-Konflikte entstehen."
|
title: t("confirm.unrelatedHistories.title"),
|
||||||
: "The local and remote repositories have separate commit histories.\n\nMerge them anyway? This may produce merge conflicts.");
|
message: t("confirm.unrelatedHistories.message"),
|
||||||
|
note: t("confirm.unrelatedHistories.note"),
|
||||||
|
confirmLabel: t("confirm.unrelatedHistories.action"),
|
||||||
|
danger: false,
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
errorMessage = appLanguage === "de"
|
errorMessage = appLanguage === "de"
|
||||||
? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert."
|
? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert."
|
||||||
@@ -4432,9 +4538,13 @@
|
|||||||
|
|
||||||
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
|
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
const shouldSync = window.confirm(
|
const shouldSync = await requestConfirmation({
|
||||||
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
|
title: t("confirm.pushRejected.title"),
|
||||||
);
|
message: t("confirm.pushRejected.message"),
|
||||||
|
note: t("confirm.pushRejected.note"),
|
||||||
|
confirmLabel: t("confirm.pushRejected.action"),
|
||||||
|
danger: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!shouldSync) {
|
if (!shouldSync) {
|
||||||
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
|
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
|
||||||
@@ -4620,7 +4730,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revertHistoryCommit(commit: GitCommit) {
|
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 () => {
|
await runOperation(`Reverting ${commit.short_hash}`, async () => {
|
||||||
applyStatus(await revertCommit(activeRepoPath, commit.hash));
|
applyStatus(await revertCommit(activeRepoPath, commit.hash));
|
||||||
await refreshRepositoryViews(activeRepoPath, { branches: false });
|
await refreshRepositoryViews(activeRepoPath, { branches: false });
|
||||||
@@ -4636,7 +4754,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function abortMerge() {
|
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 () => {
|
await runOperation("Aborting merge", async () => {
|
||||||
applyStatus(await mergeAbort(activeRepoPath));
|
applyStatus(await mergeAbort(activeRepoPath));
|
||||||
await refreshRepositoryViews(activeRepoPath);
|
await refreshRepositoryViews(activeRepoPath);
|
||||||
@@ -4649,7 +4773,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function forcePushRepo() {
|
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;
|
remoteActionForceWithLease = true;
|
||||||
await startRemoteAction("push");
|
await startRemoteAction("push");
|
||||||
}
|
}
|
||||||
@@ -4747,7 +4877,12 @@
|
|||||||
|
|
||||||
async function dropStashEntry(stash: GitStash) {
|
async function dropStashEntry(stash: GitStash) {
|
||||||
if (!activeRepoPath) return;
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
await runOperation(`Dropping ${stash.selector}`, async () => {
|
await runOperation(`Dropping ${stash.selector}`, async () => {
|
||||||
@@ -5095,9 +5230,13 @@
|
|||||||
|
|
||||||
async function undoLastCommitChange() {
|
async function undoLastCommitChange() {
|
||||||
if (!activeRepoPath || !canAmend || isBusy) return;
|
if (!activeRepoPath || !canAmend || isBusy) return;
|
||||||
const confirmed = window.confirm(
|
const confirmed = await requestConfirmation({
|
||||||
"Undo the last commit?\n\nIts changes remain staged, ready to commit again. Your working tree files are preserved.",
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
await runOperation("Undoing last commit", async () => {
|
await runOperation("Undoing last commit", async () => {
|
||||||
@@ -5116,7 +5255,13 @@
|
|||||||
|
|
||||||
async function restoreCommit(target: GitCommit) {
|
async function restoreCommit(target: GitCommit) {
|
||||||
if (!activeRepoPath) return;
|
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;
|
if (!confirmed) return;
|
||||||
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
||||||
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
||||||
@@ -5127,7 +5272,12 @@
|
|||||||
|
|
||||||
async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise<boolean> {
|
async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise<boolean> {
|
||||||
if (!activeRepoPath) return false;
|
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;
|
if (!confirmed) return false;
|
||||||
await runOperation(`Restoring ${file.path}`, async () => {
|
await runOperation(`Restoring ${file.path}`, async () => {
|
||||||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
|
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
|
||||||
@@ -5406,7 +5556,12 @@
|
|||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
if (!activeRepoPath || !selectedExplorerPath) return;
|
if (!activeRepoPath || !selectedExplorerPath) return;
|
||||||
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
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;
|
if (!confirmed) return;
|
||||||
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
||||||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
|
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
|
||||||
@@ -6481,14 +6636,20 @@
|
|||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmDialogRequest}
|
||||||
|
<ConfirmDialog
|
||||||
|
request={confirmDialogRequest}
|
||||||
|
onConfirm={() => answerConfirmation(true)}
|
||||||
|
onCancel={() => answerConfirmation(false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if pendingDiscard}
|
{#if pendingDiscard}
|
||||||
<DiscardConfirmDialog
|
<ConfirmDialog
|
||||||
files={pendingDiscard.kind === "patch" ? [pendingDiscard.file] : pendingDiscard.files}
|
request={discardConfirmRequest(pendingDiscard)}
|
||||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
|
||||||
scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
|
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onConfirm={confirmDiscard}
|
onConfirm={confirmDiscard}
|
||||||
onClose={closeDiscardConfirm}
|
onCancel={closeDiscardConfirm}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -6623,12 +6784,11 @@
|
|||||||
|
|
||||||
<!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
|
<!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
|
||||||
{#if deleteBranchTarget}
|
{#if deleteBranchTarget}
|
||||||
<BranchDeleteConfirmDialog
|
<ConfirmDialog
|
||||||
branch={deleteBranchTarget}
|
request={branchDeleteConfirmRequest(deleteBranchTarget, deleteBranchForce)}
|
||||||
force={deleteBranchForce}
|
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onConfirm={confirmDeleteBranch}
|
onConfirm={confirmDeleteBranch}
|
||||||
onClose={closeDeleteBranchDialog}
|
onCancel={closeDeleteBranchDialog}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte";
|
import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte";
|
||||||
import { credDelete, credLoad, credSave } from "../git";
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiProvider } from "../types";
|
import type { AiSettings, CommitAiProvider } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: AiSettings;
|
settings: AiSettings;
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
|
|
||||||
async function persistKey(target: CloudProvider, value: string) {
|
async function persistKey(target: CloudProvider, value: string) {
|
||||||
if (value === originalKeys[target]) return;
|
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 key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(): Promise<AiSettings> {
|
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;
|
saving = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +120,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ai-settings-form">
|
<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"; }}>
|
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||||
<Bot size={16} aria-hidden="true" />
|
<Bot size={16} aria-hidden="true" />
|
||||||
OpenAI
|
OpenAI
|
||||||
@@ -130,17 +131,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
|
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
|
||||||
<Globe size={16} aria-hidden="true" />
|
<Globe size={16} aria-hidden="true" />
|
||||||
Custom endpoint
|
{t("ai.custom")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if provider === "openai"}
|
{#if provider === "openai"}
|
||||||
<label class="cred-field">
|
<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" />
|
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-field">
|
<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">
|
<div class="cred-input">
|
||||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
@@ -158,11 +159,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if provider === "anthropic"}
|
{:else if provider === "anthropic"}
|
||||||
<label class="cred-field">
|
<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" />
|
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-field">
|
<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">
|
<div class="cred-input">
|
||||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
@@ -180,21 +181,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<label class="cred-field">
|
<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" />
|
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
|
||||||
</label>
|
</label>
|
||||||
<label class="cred-field">
|
<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" />
|
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-field">
|
<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">
|
<div class="cred-input">
|
||||||
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type={showKey ? "text" : "password"}
|
type={showKey ? "text" : "password"}
|
||||||
bind:value={customApiKey}
|
bind:value={customApiKey}
|
||||||
placeholder="Optional"
|
placeholder={t("ai.optional")}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
disabled={loadingKeys}
|
disabled={loadingKeys}
|
||||||
@@ -206,7 +207,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<Globe size={13} aria-hidden="true" />
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {FileText, FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
import {FileText, FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||||
import type { GitBlameLine } from "../types";
|
import type { GitBlameLine } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupTooltip(group: BlameGroup): string {
|
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}`;
|
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,16 +133,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<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">
|
<header class="dialog-header unified-dialog-header">
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><FileText size={18} /></span>
|
<span class="unified-dialog-icon" aria-hidden="true"><FileText size={18} /></span>
|
||||||
<div class="unified-dialog-text">
|
<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>
|
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions">
|
||||||
<span class="pill pill-count">{lines.length}</span>
|
<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" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,12 +152,12 @@
|
|||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="blank-state">
|
<div class="blank-state">
|
||||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
Loading blame...
|
{t("blame.loading")}
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="blank-state">{error}</div>
|
<div class="blank-state">{error}</div>
|
||||||
{:else if lines.length === 0}
|
{: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}
|
{:else}
|
||||||
<div class="diff-header blame-code-header">
|
<div class="diff-header blame-code-header">
|
||||||
<FileCode size={13} aria-hidden="true" />
|
<FileCode size={13} aria-hidden="true" />
|
||||||
@@ -169,23 +170,23 @@
|
|||||||
bind:value={blameSearch}
|
bind:value={blameSearch}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Search blame"
|
placeholder={t("blame.searchPlaceholder")}
|
||||||
aria-label="Search blame"
|
aria-label={t("blame.searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
{#if searchActive}
|
{#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" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="split-col-headers blame-column-headers">
|
<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-commit-col-label">{t("blame.columnCommit")}</div>
|
||||||
<div class="split-col-label blame-code-col-label">Code</div>
|
<div class="split-col-label blame-code-col-label">{t("blame.columnCode")}</div>
|
||||||
</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">
|
<div class="split-pane blame-scroll">
|
||||||
{#if groups.length === 0}
|
{#if groups.length === 0}
|
||||||
<div class="blank-state">No matches found.</div>
|
<div class="blank-state">{t("blame.noMatches")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="blame-code-table">
|
<div class="blame-code-table">
|
||||||
{#each groups as group (group.id)}
|
{#each groups as group (group.id)}
|
||||||
@@ -197,7 +198,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
<span class="blame-author">
|
<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}
|
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</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";
|
} from "@lucide/svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
type Scope = "local" | "remote";
|
type Scope = "local" | "remote";
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
@@ -307,12 +308,12 @@
|
|||||||
function branchTitle(branch: GitBranchInfo) {
|
function branchTitle(branch: GitBranchInfo) {
|
||||||
const tracking = trackingFor(branch);
|
const tracking = trackingFor(branch);
|
||||||
const lines = [branch.name];
|
const lines = [branch.name];
|
||||||
if (branch.current) lines.push("Current branch (HEAD)");
|
if (branch.current) lines.push(t("branches.tipCurrent"));
|
||||||
if (tracking.kind === "tracked") lines.push(`Tracks ${tracking.upstream}`);
|
if (tracking.kind === "tracked") lines.push(t("branches.tipTracks", { upstream: tracking.upstream }));
|
||||||
if (tracking.kind === "gone") lines.push(`Upstream ${tracking.upstream} is gone`);
|
if (tracking.kind === "gone") lines.push(t("branches.tipGone", { upstream: tracking.upstream }));
|
||||||
if (tracking.kind === "local") lines.push("Local only – not published");
|
if (tracking.kind === "local") lines.push(t("branches.tipLocalOnly"));
|
||||||
if (tracking.kind === "remote-tracked") lines.push(`Checked out locally as ${tracking.local}`);
|
if (tracking.kind === "remote-tracked") lines.push(t("branches.tipCheckedOut", { name: tracking.local }));
|
||||||
if (!branch.current) lines.push("Double-click to checkout");
|
if (!branch.current) lines.push(t("branches.tipDoubleClick"));
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +520,7 @@
|
|||||||
onclick={() => toggleBranchFolder(row.id)}
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||||
aria-expanded={open}
|
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">
|
<span class="bp-chevron" aria-hidden="true">
|
||||||
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
|
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
|
||||||
@@ -535,7 +536,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="bp-name">{row.name}</span>
|
<span class="bp-name">{row.name}</span>
|
||||||
{#if row.current && !open}
|
{#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}
|
{/if}
|
||||||
<span class="bp-count">{row.branchCount}</span>
|
<span class="bp-count">{row.branchCount}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -564,13 +565,13 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="bp-meta">
|
<span class="bp-meta">
|
||||||
{#if tracking.kind === "tracked"}
|
{#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"}
|
{: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"}
|
{: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"}
|
{: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}
|
||||||
{#if row.branch.current}
|
{#if row.branch.current}
|
||||||
<span class="bp-head-tag">HEAD</span>
|
<span class="bp-head-tag">HEAD</span>
|
||||||
@@ -581,8 +582,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
|
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title="Branch actions"
|
title={t("branches.actions")}
|
||||||
aria-label={`Actions for ${row.branch.name}`}
|
aria-label={t("branches.actionsFor", { name: row.branch.name })}
|
||||||
>
|
>
|
||||||
<Ellipsis size={13} aria-hidden="true" />
|
<Ellipsis size={13} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -591,17 +592,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/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">
|
<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">
|
<div class="branch-head-actions">
|
||||||
<button
|
<button
|
||||||
class="branch-create-toggle"
|
class="branch-create-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openCreateForm}
|
onclick={openCreateForm}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title="Create new branch"
|
title={t("branches.create")}
|
||||||
aria-label="Create new branch"
|
aria-label={t("branches.create")}
|
||||||
>
|
>
|
||||||
<Plus size={14} aria-hidden="true" />
|
<Plus size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -611,8 +612,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onToggleCollapsed}
|
onclick={onToggleCollapsed}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
title={collapsed ? "Expand branches" : "Collapse branches"}
|
title={collapsed ? t("branches.expand") : t("branches.collapse")}
|
||||||
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
|
aria-label={collapsed ? t("branches.expandPanel") : t("branches.collapsePanel")}
|
||||||
>
|
>
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<ChevronRight size={14} aria-hidden="true" />
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
@@ -626,7 +627,7 @@
|
|||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">{t("branches.openRepo")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bp-body">
|
<div class="bp-body">
|
||||||
<div class="bp-top">
|
<div class="bp-top">
|
||||||
@@ -639,14 +640,14 @@
|
|||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="new-branch-name"
|
placeholder={t("branches.namePlaceholder")}
|
||||||
aria-label="New branch name"
|
aria-label={t("branches.nameLabel")}
|
||||||
onkeydown={(event) => { if (event.key === "Escape") closeCreateForm(); }}
|
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" />
|
<Check size={14} aria-hidden="true" />
|
||||||
</button>
|
</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" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -654,7 +655,7 @@
|
|||||||
|
|
||||||
{#if currentBranch}
|
{#if currentBranch}
|
||||||
{@const tracking = trackingFor(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-icon" aria-hidden="true"><CircleDot size={14} /></span>
|
||||||
<span class="bp-current-text">
|
<span class="bp-current-text">
|
||||||
<strong>{currentBranch.name}</strong>
|
<strong>{currentBranch.name}</strong>
|
||||||
@@ -662,9 +663,9 @@
|
|||||||
{#if tracking.kind === "tracked"}
|
{#if tracking.kind === "tracked"}
|
||||||
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
|
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
|
||||||
{:else if tracking.kind === "gone"}
|
{: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}
|
{:else}
|
||||||
<Laptop size={10} aria-hidden="true" /> Not published
|
<Laptop size={10} aria-hidden="true" /> {t("branches.notPublished")}
|
||||||
{/if}
|
{/if}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
@@ -680,18 +681,18 @@
|
|||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Filter branches"
|
placeholder={t("branches.filter")}
|
||||||
aria-label="Filter branches"
|
aria-label={t("branches.filter")}
|
||||||
/>
|
/>
|
||||||
{#if filterText}
|
{#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" />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="bp-group">
|
||||||
<button
|
<button
|
||||||
class="bp-group-head"
|
class="bp-group-head"
|
||||||
@@ -701,13 +702,13 @@
|
|||||||
>
|
>
|
||||||
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
||||||
<Laptop size={12} aria-hidden="true" />
|
<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>
|
<span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showLocal}
|
{#if showLocal}
|
||||||
{#if localBranches.length === 0}
|
{#if localBranches.length === 0}
|
||||||
<div class="bp-empty">No local branches.</div>
|
<div class="bp-empty">{t("branches.emptyLocal")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render branchRows(localBranchRows)}
|
{@render branchRows(localBranchRows)}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -723,13 +724,13 @@
|
|||||||
>
|
>
|
||||||
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
||||||
<Cloud size={12} aria-hidden="true" />
|
<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>
|
<span class="bp-group-count">{filtering ? `${visibleRemote.length}/${remoteBranches.length}` : remoteBranches.length}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showRemote}
|
{#if showRemote}
|
||||||
{#if remoteBranches.length === 0}
|
{#if remoteBranches.length === 0}
|
||||||
<div class="bp-empty">No remote branches.</div>
|
<div class="bp-empty">{t("branches.emptyRemote")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render branchRows(remoteBranchRows)}
|
{@render branchRows(remoteBranchRows)}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -737,7 +738,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -750,32 +751,32 @@
|
|||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
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}>
|
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Checkout
|
{t("common.checkout")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||||
<GitCompare size={14} aria-hidden="true" />
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
Compare with...
|
{t("branches.menuCompare")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitMerge size={14} aria-hidden="true" />
|
<GitMerge size={14} aria-hidden="true" />
|
||||||
Merge into current
|
{t("branches.menuMerge")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
|
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Rebase current onto this
|
{t("branches.menuRebase")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||||
<HardDrive size={14} aria-hidden="true" />
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
Open in new worktree
|
{t("branches.menuWorktree")}
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<div class="menu-separator" role="separator"></div>
|
||||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||||
<Pencil size={14} aria-hidden="true" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
{contextBranch.remote ? t("branches.menuRenameRemote") : t("common.rename")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
@@ -783,10 +784,10 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={deleteContextBranch}
|
onclick={deleteContextBranch}
|
||||||
disabled={isBusy || contextBranch.current}
|
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" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
{contextBranch.remote ? "Delete remote" : "Delete"}
|
{contextBranch.remote ? t("branches.menuDeleteRemote") : t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -798,7 +799,7 @@
|
|||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={`Actions for branch folder ${contextFolder.name}`}
|
aria-label={t("branches.folderActionsFor", { name: contextFolder.name })}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
@@ -806,10 +807,10 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={deleteContextFolder}
|
onclick={deleteContextFolder}
|
||||||
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
|
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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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,
|
X,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: GitLfsStatus | null;
|
status: GitLfsStatus | null;
|
||||||
@@ -81,11 +83,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmPrune() {
|
let pruneConfirmOpen = $state(false);
|
||||||
const confirmed = window.confirm(isGerman
|
|
||||||
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
async function runPrune() {
|
||||||
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
pruneConfirmOpen = false;
|
||||||
if (confirmed) await onPrune();
|
await onPrune();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -199,7 +201,7 @@
|
|||||||
<footer class="lfs-footer">
|
<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><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>
|
<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>
|
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -293,3 +295,16 @@
|
|||||||
.lfs-footer button { flex: 1; }
|
.lfs-footer button { flex: 1; }
|
||||||
}
|
}
|
||||||
</style>
|
</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 { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||||
import { visibleParentResolver } from "../graphParents";
|
import { visibleParentResolver } from "../graphParents";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
fromCol: number;
|
fromCol: number;
|
||||||
@@ -310,11 +311,11 @@
|
|||||||
|
|
||||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
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);
|
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||||
if (containingBranches.length === 0) return commit.short_hash;
|
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 {
|
function segmentIsVisible(segment: GraphSegment): boolean {
|
||||||
@@ -516,7 +517,7 @@
|
|||||||
const note = await onLoadCommitNote(commit);
|
const note = await onLoadCommitNote(commit);
|
||||||
notePreviews = {
|
notePreviews = {
|
||||||
...notePreviews,
|
...notePreviews,
|
||||||
[commit.hash]: note?.trim() || "This Git note is empty.",
|
[commit.hash]: note?.trim() || t("history.noteEmpty"),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const nextErrors = new Set(notePreviewErrors);
|
const nextErrors = new Set(notePreviewErrors);
|
||||||
@@ -684,14 +685,14 @@
|
|||||||
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||||
if (branch.localOnly) {
|
if (branch.localOnly) {
|
||||||
const status = branchStatusLabel(branch);
|
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);
|
const status = branchStatusLabel(branch);
|
||||||
if (branch.trackedRemote) {
|
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}`;
|
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 {
|
function formatCommitDate(value: string): string {
|
||||||
@@ -778,11 +779,11 @@
|
|||||||
|
|
||||||
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
<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 class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">{t("history.eyebrow")}</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("history.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if graphBranchNames.length > 0}
|
{#if graphBranchNames.length > 0}
|
||||||
<div class="section-head-actions">
|
<div class="section-head-actions">
|
||||||
@@ -790,8 +791,8 @@
|
|||||||
class="graph-branch-dialog-button"
|
class="graph-branch-dialog-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openBranchDialog}
|
onclick={openBranchDialog}
|
||||||
title="Customize visible branches"
|
title={t("history.customizeBranches")}
|
||||||
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
aria-label={t("history.visibleBranches", { visible: visibleBranchCount, total: graphBranchNames.length })}
|
||||||
>
|
>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<GitBranch size={13} aria-hidden="true" />
|
||||||
Branches
|
Branches
|
||||||
@@ -802,13 +803,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">{t("history.noRepo")}</div>
|
||||||
{:else if commits.length === 0}
|
{:else if commits.length === 0}
|
||||||
<div class="blank-state">No commits returned.</div>
|
<div class="blank-state">{t("history.noCommits")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="history-list graph-list overflow-auto">
|
<div class="history-list graph-list overflow-auto">
|
||||||
{#if visibleCommits.length === 0}
|
{#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}
|
{/if}
|
||||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
@@ -884,7 +885,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||||
<div class="commit-ref-area">
|
<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}
|
{#if refSummary.primaryBranch}
|
||||||
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||||
<span
|
<span
|
||||||
@@ -900,14 +901,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if refSummary.primaryBranch.localOnly}
|
{#if refSummary.primaryBranch.localOnly}
|
||||||
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
<span class="compact-ref-local-marker" title={t("history.localOnlyHint")}>
|
||||||
LOCAL
|
{t("history.localOnlyBadge")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if refSummary.primaryTag}
|
{#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" />
|
<Tag size={10} aria-hidden="true" />
|
||||||
<span>{refSummary.primaryTag}</span>
|
<span>{refSummary.primaryTag}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -919,7 +920,7 @@
|
|||||||
onclick={() => toggleCommitRefs(item)}
|
onclick={() => toggleCommitRefs(item)}
|
||||||
aria-expanded={expandedRefsCommitHash === item.hash}
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||||
aria-controls={`commit-refs-${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}
|
+{refSummary.overflowCount}
|
||||||
</button>
|
</button>
|
||||||
@@ -928,17 +929,17 @@
|
|||||||
|
|
||||||
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||||
<div class="commit-ref-details" id={`commit-refs-${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")}
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||||
<section>
|
<section>
|
||||||
<span>Local</span>
|
<span>{t("common.local")}</span>
|
||||||
<div>
|
<div>
|
||||||
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||||
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||||
<i aria-hidden="true"></i>{branch.label}
|
<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.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>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -946,7 +947,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||||
<section>
|
<section>
|
||||||
<span>Remote</span>
|
<span>{t("common.remote")}</span>
|
||||||
<div>
|
<div>
|
||||||
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
{#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>
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||||
@@ -956,7 +957,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if refSummary.tags.length > 0}
|
{#if refSummary.tags.length > 0}
|
||||||
<section>
|
<section>
|
||||||
<span>Tags</span>
|
<span>{t("history.tags")}</span>
|
||||||
<div>
|
<div>
|
||||||
{#each refSummary.tags as tag}
|
{#each refSummary.tags as tag}
|
||||||
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||||
@@ -966,7 +967,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if refSummary.other.length > 0}
|
{#if refSummary.other.length > 0}
|
||||||
<section>
|
<section>
|
||||||
<span>Other</span>
|
<span>{t("history.other")}</span>
|
||||||
<div>
|
<div>
|
||||||
{#each refSummary.other as ref}
|
{#each refSummary.other as ref}
|
||||||
<span class="commit-ref-detail-item">{ref}</span>
|
<span class="commit-ref-detail-item">{ref}</span>
|
||||||
@@ -1005,11 +1006,11 @@
|
|||||||
onfocus={() => void loadCommitNotePreview(item)}
|
onfocus={() => void loadCommitNotePreview(item)}
|
||||||
onclick={() => openCommitNote(item)}
|
onclick={() => openCommitNote(item)}
|
||||||
disabled={isBusy}
|
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}`}
|
aria-describedby={`commit-note-preview-${item.hash}`}
|
||||||
>
|
>
|
||||||
<StickyNote size={11} aria-hidden="true" />
|
<StickyNote size={11} aria-hidden="true" />
|
||||||
<span>Note</span>
|
<span>{t("history.note")}</span>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
class="commit-note-tooltip"
|
class="commit-note-tooltip"
|
||||||
@@ -1018,8 +1019,8 @@
|
|||||||
>
|
>
|
||||||
<span class="commit-note-tooltip-head">
|
<span class="commit-note-tooltip-head">
|
||||||
<StickyNote size={12} aria-hidden="true" />
|
<StickyNote size={12} aria-hidden="true" />
|
||||||
Git Note
|
{t("history.gitNote")}
|
||||||
<small>Click to open</small>
|
<small>{t("history.clickToOpen")}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="commit-note-tooltip-body">
|
<span class="commit-note-tooltip-body">
|
||||||
{#if notePreviewLoading.has(item.hash)}
|
{#if notePreviewLoading.has(item.hash)}
|
||||||
@@ -1054,14 +1055,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if expandedCommitHashes.has(item.hash)}
|
{#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}`)}
|
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
||||||
<button
|
<button
|
||||||
class="commit-file-button"
|
class="commit-file-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onPreviewCommitFile(item, file)}
|
onclick={() => onPreviewCommitFile(item, file)}
|
||||||
disabled={isBusy}
|
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>
|
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||||
<strong>{commitFileName(file)}</strong>
|
<strong>{commitFileName(file)}</strong>
|
||||||
@@ -1081,8 +1082,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => openCommitNote(item)}
|
onclick={() => openCommitNote(item)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={`Add a Git note to ${item.short_hash}`}
|
title={t("history.addNote", { hash: item.short_hash })}
|
||||||
aria-label={`Add a Git note to ${item.short_hash}`}
|
aria-label={t("history.addNote", { hash: item.short_hash })}
|
||||||
>
|
>
|
||||||
<StickyNote size={14} aria-hidden="true" />
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1092,8 +1093,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={(event) => openCommitActionMenu(event, item)}
|
onclick={(event) => openCommitActionMenu(event, item)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title="Commit actions"
|
title={t("history.commitActions")}
|
||||||
aria-label={`Actions for ${item.short_hash}`}
|
aria-label={t("history.actionsFor", { hash: item.short_hash })}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={contextCommit?.hash === item.hash}
|
aria-expanded={contextCommit?.hash === item.hash}
|
||||||
>
|
>
|
||||||
@@ -1108,13 +1109,13 @@
|
|||||||
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
||||||
{#if isLoadingMore}
|
{#if isLoadingMore}
|
||||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
<span>Loading older commits…</span>
|
<span>{t("history.loadingOlder")}</span>
|
||||||
{:else if loadMoreError}
|
{:else if loadMoreError}
|
||||||
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
<span title={loadMoreError}>{t("history.loadOlderFailed")}</span>
|
||||||
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>{t("history.retry")}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
||||||
Load older commits
|
{t("history.loadOlder")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1128,32 +1129,32 @@
|
|||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
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}>
|
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Branch
|
{t("history.menuBranch")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||||
<StickyNote size={14} aria-hidden="true" />
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
Note
|
{t("history.note")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Restore
|
{t("history.menuRestore")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={cherryPickContextCommit}
|
onclick={cherryPickContextCommit}
|
||||||
disabled={isBusy}
|
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 size={14} aria-hidden="true" />
|
||||||
Cherry-pick
|
{t("history.menuCherryPick")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title={t("history.menuRevertHint")}>
|
||||||
<RotateCcw size={14} aria-hidden="true" /> Revert
|
<RotateCcw size={14} aria-hidden="true" /> {t("history.menuRevert")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1165,25 +1166,25 @@
|
|||||||
class="branch-filter-dialog"
|
class="branch-filter-dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Select visible branches"
|
aria-label={t("history.branchDialogLabel")}
|
||||||
>
|
>
|
||||||
<header class="branch-filter-dialog-head unified-dialog-header">
|
<header class="branch-filter-dialog-head unified-dialog-header">
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><GitBranch size={18} /></span>
|
<span class="unified-dialog-icon" aria-hidden="true"><GitBranch size={18} /></span>
|
||||||
<div class="unified-dialog-text">
|
<div class="unified-dialog-text">
|
||||||
<span class="eyebrow">Git graph</span>
|
<span class="eyebrow">{t("history.graphEyebrow")}</span>
|
||||||
<h3>Visible branches</h3>
|
<h3>{t("history.graphTitle")}</h3>
|
||||||
</div>
|
</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" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-filter-summary">
|
<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">
|
<div class="branch-filter-actions">
|
||||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>{t("history.focus")}</button>
|
||||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>{t("history.showAll")}</button>
|
||||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>{t("history.hideAll")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1197,7 +1198,7 @@
|
|||||||
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
||||||
>
|
>
|
||||||
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
{#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>
|
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
|
||||||
</button>
|
</button>
|
||||||
{#if localBranchGroupOpen}
|
{#if localBranchGroupOpen}
|
||||||
@@ -1226,7 +1227,7 @@
|
|||||||
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
||||||
>
|
>
|
||||||
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
{#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>
|
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
|
||||||
</button>
|
</button>
|
||||||
{#if remoteBranchGroupOpen}
|
{#if remoteBranchGroupOpen}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {GitMerge, AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
import {GitMerge, AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface PlanRow extends RebaseCommit {
|
interface PlanRow extends RebaseCommit {
|
||||||
@@ -71,23 +72,23 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
<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">
|
<header class="dialog-header unified-dialog-header">
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span>
|
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span>
|
||||||
<div class="unified-dialog-text">
|
<div class="unified-dialog-text">
|
||||||
<span class="eyebrow">Rewrite local history</span>
|
<span class="eyebrow">{t("rebase.eyebrow")}</span>
|
||||||
<h2 class="dialog-title">Interactive rebase</h2>
|
<h2 class="dialog-title">{t("rebase.dialogLabel")}</h2>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="interactive-rebase-body">
|
<div class="interactive-rebase-body">
|
||||||
<section class="rebase-base-bar">
|
<section class="rebase-base-bar">
|
||||||
<label>
|
<label>
|
||||||
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
<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 ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
|
<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>
|
</label>
|
||||||
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
<p>{t("rebase.hint")}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -95,24 +96,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLoading}
|
{#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}
|
{: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}
|
{: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}
|
{: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)}
|
{#each rows as row, index (row.hash)}
|
||||||
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
|
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
|
||||||
<div class="rebase-order-actions">
|
<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 === 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="Move down"><ArrowDown 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>
|
</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>
|
<code>{row.short_hash}</code>
|
||||||
<div class="rebase-commit-copy">
|
<div class="rebase-commit-copy">
|
||||||
{#if row.action === "reword"}
|
{#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}
|
{:else}
|
||||||
<strong>{row.summary}</strong>
|
<strong>{row.summary}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -124,19 +125,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if invalidSquash}
|
{#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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer">
|
<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">
|
<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}>
|
<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}
|
{#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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
|
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
|
||||||
import type { ReflogEntry } from "../types";
|
import type { ReflogEntry } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: ReflogEntry[];
|
entries: ReflogEntry[];
|
||||||
@@ -32,21 +33,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
<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">
|
<header class="dialog-header unified-dialog-header">
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><History size={18} /></span>
|
<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>
|
<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="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>
|
</header>
|
||||||
<div class="reflog-body">
|
<div class="reflog-body">
|
||||||
<aside class="reflog-list-pane">
|
<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}
|
{#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}
|
{: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}
|
{: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}`)}
|
{#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)}>
|
<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>
|
<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 error}<div class="rebase-warning error">{error}</div>{/if}
|
||||||
{#if selected}
|
{#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>
|
<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>
|
<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" /> Preview changes to current HEAD</button>
|
<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-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>
|
<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>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
|
<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()}>
|
<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}
|
{#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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
|
<div class="blank-state">{t("reflog.selectEntry")}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { AiSettings } from "../types";
|
import type { AiSettings } from "../types";
|
||||||
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
||||||
|
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
import CommentEditor from "./CommentEditor.svelte";
|
import CommentEditor from "./CommentEditor.svelte";
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
@@ -340,16 +342,35 @@
|
|||||||
return de ? "Request wieder öffnen" : "Reopen request";
|
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) {
|
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||||
const source = activeSource;
|
const source = activeSource;
|
||||||
if (!source || actionBusyId) return;
|
if (!source || actionBusyId) return;
|
||||||
if (action !== "approve") {
|
if (action !== "approve") {
|
||||||
const prompt = action === "merge"
|
const confirmed = await askReviewConfirmation(request, action);
|
||||||
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
|
if (!confirmed) return;
|
||||||
: 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;
|
|
||||||
}
|
}
|
||||||
actionMenuId = "";
|
actionMenuId = "";
|
||||||
actionNotice = "";
|
actionNotice = "";
|
||||||
@@ -636,3 +657,11 @@
|
|||||||
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
|
.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)}
|
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
{#if reviewConfirmRequest}
|
||||||
|
<ConfirmDialog
|
||||||
|
request={reviewConfirmRequest}
|
||||||
|
onConfirm={() => answerReviewConfirmation(true)}
|
||||||
|
onCancel={() => answerReviewConfirmation(false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
|
import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
|
||||||
import type { GitStash } from "../types";
|
import type { GitStash } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stashes: GitStash[];
|
stashes: GitStash[];
|
||||||
@@ -42,11 +43,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<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">
|
<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}
|
disabled={!hasRepository || isBusy || changedCount === 0}
|
||||||
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
|
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
|
||||||
<Plus size={14} aria-hidden="true" />
|
<Plus size={14} aria-hidden="true" />
|
||||||
@@ -57,8 +58,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onToggleCollapsed}
|
onclick={onToggleCollapsed}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
title={collapsed ? t("stashes.expand") : t("stashes.collapse")}
|
||||||
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
aria-label={collapsed ? t("stashes.expand") : t("stashes.collapse")}
|
||||||
>
|
>
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<ChevronRight size={14} aria-hidden="true" />
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">{t("stashes.noRepo")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if createOpen}
|
{#if createOpen}
|
||||||
<div class="stash-create">
|
<div class="stash-create">
|
||||||
@@ -80,8 +81,8 @@
|
|||||||
class="stash-input"
|
class="stash-input"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
placeholder="Optional message"
|
placeholder={t("stashes.messagePlaceholder")}
|
||||||
aria-label="Stash message"
|
aria-label={t("stashes.messageLabel")}
|
||||||
disabled={isBusy || changedCount === 0}
|
disabled={isBusy || changedCount === 0}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
|
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
|
||||||
@@ -89,24 +90,24 @@
|
|||||||
/>
|
/>
|
||||||
<label class="stash-check">
|
<label class="stash-check">
|
||||||
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
|
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
|
||||||
Untracked
|
{t("stashes.untracked")}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
class="btn-sm stash-save-button"
|
class="btn-sm stash-save-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={submitPush}
|
onclick={submitPush}
|
||||||
disabled={isBusy || changedCount === 0}
|
disabled={isBusy || changedCount === 0}
|
||||||
title="Save current working tree changes to a stash"
|
title={t("stashes.saveHint")}
|
||||||
>
|
>
|
||||||
<Archive size={14} aria-hidden="true" />
|
<Archive size={14} aria-hidden="true" />
|
||||||
Stash
|
{t("stashes.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stashes.length === 0}
|
{#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}
|
{:else}
|
||||||
<div class="stash-list">
|
<div class="stash-list">
|
||||||
{#each stashes as stash (stash.selector)}
|
{#each stashes as stash (stash.selector)}
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{stash.selector}
|
{stash.selector}
|
||||||
{#if stash.branch}
|
{#if stash.branch}
|
||||||
on {stash.branch}
|
{t("stashes.on", { branch: stash.branch })}
|
||||||
{/if}
|
{/if}
|
||||||
{#if stash.date}
|
{#if stash.date}
|
||||||
- {stash.date}
|
- {stash.date}
|
||||||
@@ -124,17 +125,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stash-actions">
|
<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" />
|
<Download size={13} aria-hidden="true" />
|
||||||
Apply
|
{t("stashes.apply")}
|
||||||
</button>
|
</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" />
|
<Upload size={13} aria-hidden="true" />
|
||||||
Pop
|
{t("stashes.pop")}
|
||||||
</button>
|
</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" />
|
<Trash2 size={13} aria-hidden="true" />
|
||||||
Drop
|
{t("stashes.drop")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||||
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
changedFiles: GitFileStatus[];
|
changedFiles: GitFileStatus[];
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
function statusContextParent(label: string): string {
|
function statusContextParent(label: string): string {
|
||||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const separator = normalized.lastIndexOf("/");
|
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() {
|
function closeStatusContextMenu() {
|
||||||
@@ -410,58 +411,58 @@
|
|||||||
|
|
||||||
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
<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 class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Workspace</span>
|
<span class="eyebrow">{t("status.eyebrow")}</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("status.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-view-switch" role="group" aria-label="Changes view">
|
<div class="status-view-switch" role="group" aria-label={t("status.viewGroup")}>
|
||||||
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
|
<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>List</span>
|
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>{t("status.viewListShort")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
|
<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>Tree</span>
|
<FolderTree size={13} aria-hidden="true" /><span>{t("status.viewTreeShort")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-head-actions">
|
<div class="status-head-actions">
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
<span class="pill pill-count">{t("status.staged", { count: stagedCount })}</span>
|
||||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
<span class="pill pill-count">{t("status.unstaged", { count: unstagedCount })}</span>
|
||||||
{#if hasRepository && changedFiles.length > 0}
|
{#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">
|
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title={t("status.discardAllHint")}>
|
||||||
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
<RotateCcw size={13} aria-hidden="true" /> {t("status.discardAll")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">{t("status.noRepo")}</div>
|
||||||
{:else if status?.clean}
|
{: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}
|
{:else if changedFiles.length === 0}
|
||||||
<div class="blank-state">No file changes returned.</div>
|
<div class="blank-state">{t("status.noChanges")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="status-lanes">
|
<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">
|
<header class="status-lane-head">
|
||||||
<div class="status-lane-title">
|
<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>
|
<span class="status-lane-count">{unstagedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-lane-actions">
|
<div class="status-lane-actions">
|
||||||
{#if selectedUnstagedCount > 1}
|
{#if selectedUnstagedCount > 1}
|
||||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
<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}
|
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title={t("status.stageAllHint")}>
|
||||||
<ArrowRight size={13} aria-hidden="true" /> Stage all
|
<ArrowRight size={13} aria-hidden="true" /> {t("status.stageAll")}
|
||||||
</button>
|
</button>
|
||||||
{#if selectedUnstagedCount > 1}
|
{#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`}>
|
<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" /> Discard {selectedUnstagedCount}
|
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedUnstagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -479,20 +480,20 @@
|
|||||||
{@const file = row.file}
|
{@const file = row.file}
|
||||||
{@const stageTargets = selectedStageTargets(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])}>
|
<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>
|
<strong>{fileName(file)}</strong>
|
||||||
<span>{displayPath(file)}</span>
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||||
<div class="status-file-actions">
|
<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={() => 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="Show unstaged details"><FileDiff 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="Discard unstaged changes"><RotateCcw 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -500,25 +501,25 @@
|
|||||||
<span><ArrowRight size={12} /></span>
|
<span><ArrowRight size={12} /></span>
|
||||||
</div>
|
</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">
|
<header class="status-lane-head">
|
||||||
<div class="status-lane-title">
|
<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>
|
<span class="status-lane-count">{stagedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-lane-actions">
|
<div class="status-lane-actions">
|
||||||
{#if selectedStagedCount > 1}
|
{#if selectedStagedCount > 1}
|
||||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
<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}
|
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title={t("status.unstageAllHint")}>
|
||||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
|
<ArrowLeft size={13} aria-hidden="true" /> {t("status.unstageAll")}
|
||||||
</button>
|
</button>
|
||||||
{#if selectedStagedCount > 1}
|
{#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`}>
|
<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" /> Discard {selectedStagedCount}
|
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedStagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -536,20 +537,20 @@
|
|||||||
{@const file = row.file}
|
{@const file = row.file}
|
||||||
{@const unstageTargets = selectedUnstageTargets(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])}>
|
<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>
|
<strong>{fileName(file)}</strong>
|
||||||
<span>{displayPath(file)}</span>
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||||
<div class="status-file-actions">
|
<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={() => 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="Show staged details"><FileDiff 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="Discard staged changes"><RotateCcw 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -568,7 +569,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
||||||
</div>
|
</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 class="status-panel-overlay-bar"><span></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,17 +577,17 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if statusContextTarget}
|
{#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">
|
<div class="status-context-label">
|
||||||
<span class="status-context-object-icon" aria-hidden="true">
|
<span class="status-context-object-icon" aria-hidden="true">
|
||||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-context-object-copy">
|
<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>
|
<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 class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||||
</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}
|
{statusContextTarget.files.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -595,56 +596,56 @@
|
|||||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
<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" ? "Add to the next commit" : "Move back to working changes"}</span>
|
<span>{statusContextTarget.lane === "unstaged" ? t("status.menuStageHint") : t("status.menuUnstageHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
<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-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>Stash {statusContextTarget.kind}</strong>
|
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : t("status.menuStashFile")}</strong>
|
||||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
<span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||||
<div class="menu-separator" role="separator"></div>
|
<div class="menu-separator" role="separator"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusContextCanStopTracking}
|
{#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">
|
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : t("status.menuStopTrackingFile")}</strong>
|
||||||
<span>Keep it on disk and remove it from Git</span>
|
<span>{t("status.menuStopTrackingNote")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusContextCanIgnore}
|
{#if statusContextCanIgnore}
|
||||||
{#if statusContextTarget.kind === "file"}
|
{#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-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>Ignore file</strong>
|
<strong>{t("status.menuIgnoreFile")}</strong>
|
||||||
<span>Add only this file to .gitignore</span>
|
<span>{t("status.menuIgnoreFileNote")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusContextIgnoreExtension}
|
{#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-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
<strong>{t("status.menuIgnoreExt", { ext: statusContextIgnoreExtension })}</strong>
|
||||||
<span>Match this file type repository-wide</span>
|
<span>{t("status.menuIgnoreExtNote")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
{#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-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||||
<span class="status-context-action-copy">
|
<span class="status-context-action-copy">
|
||||||
<strong>Ignore folder</strong>
|
<strong>{t("status.menuIgnoreFolder")}</strong>
|
||||||
<span>Add this folder and its contents to .gitignore</span>
|
<span>{t("status.menuIgnoreFolderNote")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import type { GitTag } from "../types";
|
import type { GitTag } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
interface Props {
|
interface Props {
|
||||||
tags: GitTag[];
|
tags: GitTag[];
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
@@ -93,21 +94,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeTagContextMenu} on:keydown={(event) => { if (event.key === "Escape") closeTagContextMenu(); }} on:contextmenu|capture={closeTagContextMenu} />
|
<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">
|
<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">
|
<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>
|
<span class="pill pill-count">{tags.length}</span>
|
||||||
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
|
<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}
|
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div class="sidebar-tags-list">
|
<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}
|
{:else}
|
||||||
{#if tagCreateOpen}
|
{#if tagCreateOpen}
|
||||||
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
||||||
@@ -119,27 +120,27 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="v1.0.0"
|
placeholder="v1.0.0"
|
||||||
aria-label="New tag name"
|
aria-label={t("tags.nameLabel")}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
bind:value={newTagMessage}
|
bind:value={newTagMessage}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Message (optional)"
|
placeholder={t("tags.messagePlaceholder")}
|
||||||
aria-label="Tag message"
|
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" />
|
<Check size={14} aria-hidden="true" />
|
||||||
</button>
|
</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" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tags.length === 0}
|
{#if tags.length === 0}
|
||||||
<div class="branch-empty">No tags.</div>
|
<div class="branch-empty">{t("tags.empty")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each tags as tag (tag.name)}
|
{#each tags as tag (tag.name)}
|
||||||
<article
|
<article
|
||||||
@@ -167,16 +168,16 @@
|
|||||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
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}>
|
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
|
||||||
<Upload size={14} aria-hidden="true" />
|
<Upload size={14} aria-hidden="true" />
|
||||||
Push to remote
|
{t("tags.push")}
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<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" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
Delete
|
{t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
X,
|
X,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
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";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
type CreateMode = "existing" | "new" | "detached";
|
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))));
|
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
|
||||||
|
|
||||||
function displayName(worktree: GitWorktree): string {
|
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 {
|
function pathName(path: string): string {
|
||||||
@@ -117,7 +119,7 @@
|
|||||||
|
|
||||||
async function chooseDestination(current = "") {
|
async function chooseDestination(current = "") {
|
||||||
const selected = await openDialog({
|
const selected = await openDialog({
|
||||||
title: current ? "Choose new worktree location" : "Choose worktree folder",
|
title: current ? t("worktreeDialog.chooseNewLocation") : t("worktreeDialog.chooseFolder"),
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
defaultPath: current || undefined,
|
defaultPath: current || undefined,
|
||||||
@@ -175,14 +177,33 @@
|
|||||||
forceRemoval = false;
|
forceRemoval = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRemoval() {
|
async function confirmRemoval(force: boolean) {
|
||||||
if (!pendingRemoval) return;
|
if (!pendingRemoval) return;
|
||||||
if (await onRemove(pendingRemoval, forceRemoval)) {
|
forceRemoval = force;
|
||||||
|
if (await onRemove(pendingRemoval, force)) {
|
||||||
pendingRemoval = null;
|
pendingRemoval = null;
|
||||||
forceRemoval = false;
|
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) {
|
function requestLock(worktree: GitWorktree) {
|
||||||
pendingLock = worktree;
|
pendingLock = worktree;
|
||||||
lockReason = "";
|
lockReason = "";
|
||||||
@@ -203,28 +224,28 @@
|
|||||||
<div class="worktree-dialog-heading unified-dialog-heading">
|
<div class="worktree-dialog-heading unified-dialog-heading">
|
||||||
<span class="worktree-dialog-mark unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
<span class="worktree-dialog-mark unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
||||||
<div class="unified-dialog-text">
|
<div class="unified-dialog-text">
|
||||||
<span class="eyebrow">Parallel workspaces</span>
|
<span class="eyebrow">{t("worktreeDialog.eyebrow")}</span>
|
||||||
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
|
<p class="dialog-title" id="worktree-dialog-title">{t("worktrees.title")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<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" />
|
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||||
Refresh
|
{t("worktreeDialog.refreshShort")}
|
||||||
</button>
|
</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" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="worktree-summary">
|
<div class="worktree-summary">
|
||||||
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
|
<div><strong>{linkedCount}</strong><span>{t("worktreeDialog.linked")}</span></div>
|
||||||
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</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>stale entries</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}>
|
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
|
||||||
<Plus size={15} aria-hidden="true" />
|
<Plus size={15} aria-hidden="true" />
|
||||||
New worktree
|
{t("worktreeDialog.new")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -237,33 +258,33 @@
|
|||||||
<form class="worktree-create-card" onsubmit={submitCreate}>
|
<form class="worktree-create-card" onsubmit={submitCreate}>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Create</span>
|
<span class="eyebrow">{t("worktreeDialog.createEyebrow")}</span>
|
||||||
<h3>Choose what this workspace should track</h3>
|
<h3>{t("worktreeDialog.createTitle")}</h3>
|
||||||
</div>
|
</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" />
|
<X size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</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"; }}>
|
<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>
|
||||||
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
|
<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>
|
||||||
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="worktree-create-fields">
|
<div class="worktree-create-fields">
|
||||||
{#if createMode === "existing"}
|
{#if createMode === "existing"}
|
||||||
<label>
|
<label>
|
||||||
<span>Branch</span>
|
<span>{t("common.branch")}</span>
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
value={selectedBranch}
|
value={selectedBranch}
|
||||||
placeholder="Select a local branch"
|
placeholder={t("worktreeDialog.selectBranch")}
|
||||||
options={localBranches.map((branch) => ({
|
options={localBranches.map((branch) => ({
|
||||||
value: branch.name,
|
value: branch.name,
|
||||||
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
|
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
|
||||||
@@ -275,26 +296,26 @@
|
|||||||
</label>
|
</label>
|
||||||
{:else if createMode === "new"}
|
{:else if createMode === "new"}
|
||||||
<label>
|
<label>
|
||||||
<span>New branch name</span>
|
<span>{t("worktreeDialog.newBranchName")}</span>
|
||||||
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
|
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.newBranchPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Start point</span>
|
<span>{t("worktreeDialog.startPoint")}</span>
|
||||||
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.startPointPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<label>
|
<label>
|
||||||
<span>Commit or ref</span>
|
<span>{t("worktreeDialog.commitOrRef")}</span>
|
||||||
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="worktree-path-field">
|
<label class="worktree-path-field">
|
||||||
<span>Folder</span>
|
<span>{t("worktreeDialog.folder")}</span>
|
||||||
<div>
|
<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}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -303,7 +324,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<label class="worktree-check">
|
<label class="worktree-check">
|
||||||
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
|
<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>
|
</label>
|
||||||
<button
|
<button
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@@ -311,16 +332,16 @@
|
|||||||
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
|
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}
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
|
||||||
Create worktree
|
{t("worktreeDialog.createAction")}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLoading && worktrees.length === 0}
|
{#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}
|
{: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}
|
{:else}
|
||||||
<div class="worktree-list">
|
<div class="worktree-list">
|
||||||
{#each worktrees as worktree (worktree.path)}
|
{#each worktrees as worktree (worktree.path)}
|
||||||
@@ -339,11 +360,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="worktree-badges">
|
<div class="worktree-badges">
|
||||||
{#if worktree.is_main}<span>Main</span>{/if}
|
{#if worktree.is_main}<span>{t("worktreeDialog.main")}</span>{/if}
|
||||||
{#if worktree.is_current}<span class="active">Open</span>{/if}
|
{#if worktree.is_current}<span class="active">{t("worktreeDialog.open")}</span>{/if}
|
||||||
{#if worktree.detached}<span>Detached</span>{/if}
|
{#if worktree.detached}<span>{t("worktreeDialog.detached")}</span>{/if}
|
||||||
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</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">Stale</span>{/if}
|
{#if worktree.prunable || worktree.missing}<span class="danger">{t("worktreeDialog.staleBadge")}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -360,31 +381,31 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
|
<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>
|
</button>
|
||||||
<div class="worktree-card-actions">
|
<div class="worktree-card-actions">
|
||||||
{#if worktree.prunable}
|
{#if worktree.prunable}
|
||||||
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
|
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title={t("worktreeDialog.repairHint")}>
|
||||||
<Wrench size={14} aria-hidden="true" />Repair
|
<Wrench size={14} aria-hidden="true" />{t("worktreeDialog.repair")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !worktree.is_main && !worktree.missing}
|
{#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">
|
<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" />Move
|
<FolderInput size={14} aria-hidden="true" />{t("worktreeDialog.move")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if worktree.locked}
|
{#if worktree.locked}
|
||||||
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
|
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title={t("worktreeDialog.unlockHint")}>
|
||||||
<Unlock size={14} aria-hidden="true" />Unlock
|
<Unlock size={14} aria-hidden="true" />{t("worktreeDialog.unlock")}
|
||||||
</button>
|
</button>
|
||||||
{:else if !worktree.is_main}
|
{:else if !worktree.is_main}
|
||||||
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
|
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title={t("worktreeDialog.lockHint")}>
|
||||||
<Lock size={14} aria-hidden="true" />Lock
|
<Lock size={14} aria-hidden="true" />{t("worktreeDialog.lock")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !worktree.is_main}
|
{#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"}>
|
<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" />Remove
|
<Trash2 size={14} aria-hidden="true" />{t("worktreeDialog.remove")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +418,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="worktree-dialog-footer">
|
<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}>
|
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
|
||||||
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
|
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
|
||||||
</button>
|
</button>
|
||||||
@@ -406,37 +427,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if pendingRemoval}
|
{#if pendingRemoval}
|
||||||
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
<ConfirmDialog
|
||||||
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
|
request={removalConfirmRequest(pendingRemoval)}
|
||||||
<header class="dialog-header unified-dialog-header">
|
{isBusy}
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
onConfirm={(force) => { void confirmRemoval(force); }}
|
||||||
<div class="unified-dialog-text">
|
onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pendingLock}
|
{#if pendingLock}
|
||||||
@@ -445,20 +441,20 @@
|
|||||||
<header class="dialog-header unified-dialog-header">
|
<header class="dialog-header unified-dialog-header">
|
||||||
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
|
||||||
<div class="unified-dialog-text">
|
<div class="unified-dialog-text">
|
||||||
<span class="eyebrow">Protect worktree</span>
|
<span class="eyebrow">{t("worktreeDialog.lockEyebrow")}</span>
|
||||||
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
|
<p class="dialog-title" id="worktree-lock-title">{t("worktreeDialog.lockTitle", { name: displayName(pendingLock) })}</p>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<div class="worktree-lock-body">
|
<div class="worktree-lock-body">
|
||||||
<label>
|
<label>
|
||||||
<span>Reason <small>optional</small></span>
|
<span>{t("worktreeDialog.reason")} <small>{t("worktreeDialog.optional")}</small></span>
|
||||||
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
|
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder={t("worktreeDialog.reasonPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<footer class="discard-confirm-actions">
|
<footer class="discard-confirm-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</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" />Lock</button>
|
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />{t("worktreeDialog.lock")}</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
|
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
|
||||||
import type { GitWorktree } from "../types";
|
import type { GitWorktree } from "../types";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
worktrees: GitWorktree[];
|
worktrees: GitWorktree[];
|
||||||
@@ -19,18 +20,18 @@
|
|||||||
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
|
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
|
||||||
</script>
|
</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">
|
<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">
|
<div class="branch-head-actions">
|
||||||
<button class="branch-create-toggle" type="button" onclick={onManage} disabled={!hasRepository || isBusy}
|
<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" />
|
<Plus size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span class="pill pill-count">{loading && linkedWorktrees.length === 0 ? "…" : linkedWorktrees.length}</span>
|
<span class="pill pill-count">{loading && linkedWorktrees.length === 0 ? "…" : linkedWorktrees.length}</span>
|
||||||
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
|
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
|
||||||
aria-expanded={!collapsed} title={collapsed ? "Expand worktrees" : "Collapse worktrees"}
|
aria-expanded={!collapsed} title={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}
|
||||||
aria-label={collapsed ? "Expand worktrees" : "Collapse worktrees"}>
|
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}
|
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,30 +39,30 @@
|
|||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div class="sidebar-worktree-list">
|
<div class="sidebar-worktree-list">
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<p class="branch-empty">Open a repository to list worktrees.</p>
|
<p class="branch-empty">{t("worktrees.openRepo")}</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="sidebar-worktree-error" role="status">
|
<div class="sidebar-worktree-error" role="status">
|
||||||
<span>{error}</span>
|
<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>
|
</div>
|
||||||
{:else if loading && linkedWorktrees.length === 0}
|
{: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}
|
{:else if linkedWorktrees.length === 0}
|
||||||
<p class="branch-empty">No linked worktrees.</p>
|
<p class="branch-empty">{t("worktrees.empty")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each linkedWorktrees as worktree (worktree.path)}
|
{#each linkedWorktrees as worktree (worktree.path)}
|
||||||
<button class="sidebar-worktree-row" class:current={worktree.is_current} type="button"
|
<button class="sidebar-worktree-row" class:current={worktree.is_current} type="button"
|
||||||
onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}
|
onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}
|
||||||
aria-current={worktree.is_current ? "location" : undefined}
|
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" />
|
<HardDrive size={15} aria-hidden="true" />
|
||||||
<span class="sidebar-worktree-info">
|
<span class="sidebar-worktree-info">
|
||||||
<strong>{name(worktree.path)}</strong>
|
<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>
|
</span>
|
||||||
{#if worktree.locked}<Lock size={12} aria-label="Locked" />{/if}
|
{#if worktree.locked}<Lock size={12} aria-label={t("worktrees.locked")} />{/if}
|
||||||
{#if worktree.missing}<span class="pill">Missing</span>
|
{#if worktree.missing}<span class="pill">{t("worktrees.missing")}</span>
|
||||||
{:else if worktree.is_current}<span class="pill pill-active">Current</span>{/if}
|
{:else if worktree.is_current}<span class="pill pill-active">{t("worktrees.current")}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/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