feat: add input/checkbox to confirm dialog and multi-selection actions

Introduce richer confirmation dialogs and wire them through the UI so actions
can collect an optional single-line input and a checkbox option.

- ConfirmDialog: support optional input and checkbox (with defaultChecked),
  expose onConfirm(result: {checked, value}). Focus/selects input when present
  and blocks confirm while required inputs/checkboxes are missing.
- App: add askConfirmation(...) returning {confirmed, value, checked} and keep
  requestConfirmation(...) as a convenience boolean wrapper. Update answer flow
  to pass the full result. Use the new prompt in stashStatusFiles to collect a
  stash message and "include untracked" option before saving.
- Status/Explorer/Worktree: add multi-selection support for stop-tracking and
  stash operations. onStopTracking now accepts an array of paths and a new
  "selection" kind; status context menu shows selection counts and uses a
  CopyCheck icon for selections. Added corresponding i18n messages.

This change keeps existing UX but enables collecting extra confirmation data
and acting on multi-file selections.
This commit is contained in:
2026-09-17 22:58:30 +02:00
parent 8c99e15dc9
commit acf8898b98
6 changed files with 152 additions and 41 deletions
+47 -14
View File
@@ -438,8 +438,9 @@
let externalToolsDetectionUnavailable = false; let externalToolsDetectionUnavailable = false;
let errorMessage = ""; let errorMessage = "";
let operation = ""; let operation = "";
type ConfirmAnswer = { confirmed: boolean; value: string; checked: boolean };
let confirmDialogRequest: ConfirmRequest | null = null; let confirmDialogRequest: ConfirmRequest | null = null;
let confirmDialogResolve: ((confirmed: boolean) => void) | null = null; let confirmDialogResolve: ((answer: ConfirmAnswer) => void) | null = null;
let compareFrom = ""; let compareFrom = "";
let compareTo = ""; let compareTo = "";
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
@@ -1289,15 +1290,20 @@
setLanguage(next); setLanguage(next);
} }
/** Show the confirmation dialog and resolve once the user answers. */ /** Show the dialog and resolve with the answer, including input and checkbox. */
function requestConfirmation(request: ConfirmRequest): Promise<boolean> { function askConfirmation(request: ConfirmRequest): Promise<ConfirmAnswer> {
confirmDialogResolve?.(false); confirmDialogResolve?.({ confirmed: false, value: "", checked: false });
confirmDialogRequest = request; confirmDialogRequest = request;
return new Promise<boolean>((resolve) => { return new Promise<ConfirmAnswer>((resolve) => {
confirmDialogResolve = resolve; confirmDialogResolve = resolve;
}); });
} }
/** Yes/no only, for the many confirmations that need nothing else. */
async function requestConfirmation(request: ConfirmRequest): Promise<boolean> {
return (await askConfirmation(request)).confirmed;
}
/** Confirmation shown before deleting a local or remote branch. */ /** Confirmation shown before deleting a local or remote branch. */
function branchDeleteConfirmRequest(branch: GitBranchInfo, force: boolean): ConfirmRequest { function branchDeleteConfirmRequest(branch: GitBranchInfo, force: boolean): ConfirmRequest {
const remoteName = branch.remote ? branch.name.split("/")[0] : ""; const remoteName = branch.remote ? branch.name.split("/")[0] : "";
@@ -1356,11 +1362,11 @@
}; };
} }
function answerConfirmation(confirmed: boolean) { function answerConfirmation(confirmed: boolean, value = "", checked = false) {
const resolve = confirmDialogResolve; const resolve = confirmDialogResolve;
confirmDialogRequest = null; confirmDialogRequest = null;
confirmDialogResolve = null; confirmDialogResolve = null;
resolve?.(confirmed); resolve?.({ confirmed, value, checked });
} }
function applyThemePreference(next: AppTheme) { function applyThemePreference(next: AppTheme) {
@@ -4797,8 +4803,34 @@
} }
async function stashStatusFiles(files: GitFileStatus[], label: string) { async function stashStatusFiles(files: GitFileStatus[], label: string) {
const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`; if (files.length === 0) return;
await saveStash(`Gitty: ${suffix}`, true, files); const suffix = files.length === 1
? files[0].path
: label ? `${label} (${files.length} files)` : `${files.length} files`;
const fallbackMessage = `Gitty: ${suffix}`;
const answer = await askConfirmation({
eyebrow: t("confirm.stashFiles.eyebrow"),
title: files.length === 1 ? t("confirm.stashFiles.titleOne") : t("confirm.stashFiles.title", { count: files.length }),
message: t("confirm.stashFiles.message"),
items: files.map((file) => file.path),
input: {
label: t("confirm.stashFiles.inputLabel"),
placeholder: fallbackMessage,
value: fallbackMessage,
optional: true,
},
checkbox: {
label: t("confirm.stashFiles.untracked"),
note: t("confirm.stashFiles.untrackedNote"),
defaultChecked: true,
},
confirmLabel: t("stashes.save"),
danger: false,
});
if (!answer.confirmed) return;
await saveStash(answer.value.trim() || fallbackMessage, answer.checked, files);
} }
async function applyStashEntry(stash: GitStash) { async function applyStashEntry(stash: GitStash) {
@@ -4884,12 +4916,13 @@
}); });
} }
async function stopTrackingTarget(target: string, kind: "file" | "folder") { async function stopTrackingTarget(targets: string[], kind: "file" | "folder" | "selection") {
if (!activeRepoPath || !target) return; const paths = targets.filter(Boolean);
if (!activeRepoPath || paths.length === 0) return;
await runOperation(`Stopping tracking for ${kind}`, async () => { await runOperation(`Stopping tracking for ${kind}`, async () => {
applyStatus(await untrackPaths(activeRepoPath, [target])); applyStatus(await untrackPaths(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
trackEvent("git_paths_untracked", { kind }); trackEvent("git_paths_untracked", { kind, paths: paths.length });
}); });
} }
@@ -6608,7 +6641,7 @@
{#if confirmDialogRequest} {#if confirmDialogRequest}
<ConfirmDialog <ConfirmDialog
request={confirmDialogRequest} request={confirmDialogRequest}
onConfirm={() => answerConfirmation(true)} onConfirm={(result) => answerConfirmation(true, result.value, result.checked)}
onCancel={() => answerConfirmation(false)} onCancel={() => answerConfirmation(false)}
/> />
{/if} {/if}
+49 -9
View File
@@ -19,8 +19,10 @@
note?: string; note?: string;
confirmLabel?: string; confirmLabel?: string;
cancelLabel?: string; cancelLabel?: string;
/** Optional opt-in the user must tick before confirming, e.g. "delete anyway". */ /** Optional opt-in, e.g. "delete anyway" (required) or "include untracked". */
checkbox?: { label: string; note?: string; required?: boolean }; checkbox?: { label: string; note?: string; required?: boolean; defaultChecked?: boolean };
/** Optional single-line input, e.g. a stash message. */
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
/** Destructive actions get the red confirm button and warning icon. */ /** Destructive actions get the red confirm button and warning icon. */
danger?: boolean; danger?: boolean;
} }
@@ -28,8 +30,8 @@
interface Props { interface Props {
request: ConfirmRequest; request: ConfirmRequest;
isBusy?: boolean; isBusy?: boolean;
/** `checked` is the state of the optional checkbox. */ /** Carries the state of the optional checkbox and input. */
onConfirm: (checked: boolean) => void; onConfirm: (result: { checked: boolean; value: string }) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -42,18 +44,28 @@
let danger = $derived(request.danger !== false); let danger = $derived(request.danger !== false);
let items = $derived(request.items ?? []); let items = $derived(request.items ?? []);
let checked = $state(false); let checked = $state(false);
let blocked = $derived(Boolean(request.checkbox?.required) && !checked); let value = $state("");
let inputElement = $state<HTMLInputElement | null>(null);
let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0);
let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput);
$effect(() => { $effect(() => {
// Reset the opt-in whenever a different confirmation is shown. // Start from the defaults again whenever a different confirmation is shown.
request.title; request.title;
checked = false; checked = request.checkbox?.defaultChecked ?? false;
value = request.input?.value ?? "";
}); });
$effect(() => { $effect(() => {
confirmButton?.focus(); // The input is the first thing to fill in when there is one.
if (inputElement) inputElement.select();
else confirmButton?.focus();
}); });
function submit() {
if (!isBusy && !blocked) onConfirm({ checked, value });
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") { if (event.key === "Escape") {
event.stopPropagation(); event.stopPropagation();
@@ -116,6 +128,22 @@
</ul> </ul>
{/if} {/if}
{#if request.input}
<label class="confirm-input">
<span>{request.input.label}</span>
<input
bind:this={inputElement}
bind:value
type="text"
autocomplete="off"
spellcheck="false"
placeholder={request.input.placeholder ?? ""}
disabled={isBusy}
onkeydown={(event) => { if (event.key === "Enter") { event.preventDefault(); submit(); } }}
/>
</label>
{/if}
{#if request.checkbox} {#if request.checkbox}
<label class="confirm-check"> <label class="confirm-check">
<input type="checkbox" bind:checked disabled={isBusy} /> <input type="checkbox" bind:checked disabled={isBusy} />
@@ -140,7 +168,7 @@
bind:this={confirmButton} bind:this={confirmButton}
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`} class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
type="button" type="button"
onclick={() => onConfirm(checked)} onclick={submit}
disabled={isBusy || blocked} disabled={isBusy || blocked}
> >
{#if isBusy} {#if isBusy}
@@ -206,6 +234,13 @@
background: color-mix(in srgb, var(--color-accent) 9%, transparent); background: color-mix(in srgb, var(--color-accent) 9%, transparent);
} }
.confirm-dialog .confirm-action { min-width: 116px; } .confirm-dialog .confirm-action { min-width: 116px; }
.confirm-dialog .confirm-input { display: grid; gap: 5px; }
.confirm-dialog .confirm-input span {
color: var(--color-ink-muted);
font-size: 11.5px;
font-weight: 650;
}
.confirm-dialog .confirm-input input { height: 32px; font-size: 12.5px; }
.confirm-dialog .confirm-check { .confirm-dialog .confirm-check {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -217,6 +252,11 @@
background: rgba(255, 90, 103, 0.05); background: rgba(255, 90, 103, 0.05);
cursor: pointer; cursor: pointer;
} }
.confirm-dialog:not(.danger) .confirm-check {
border-color: var(--color-border-subtle);
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
}
.confirm-dialog:not(.danger) .confirm-check input { accent-color: var(--color-accent); }
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: #e86060; } .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 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 strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
+2 -2
View File
@@ -51,7 +51,7 @@
onFileHistory: (node: ExplorerNode) => void; onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void; onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void; onStopTracking: (targets: string[], kind: "file" | "folder") => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
} }
@@ -268,7 +268,7 @@
const node = contextNode; const node = contextNode;
if (!node) return; if (!node) return;
closeFileContextMenu(); closeFileContextMenu();
onStopTracking(node.path, node.kind); onStopTracking([node.path], node.kind);
} }
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
+38 -15
View File
@@ -3,7 +3,7 @@
Archive, Archive,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
FileDiff, CopyCheck, FileDiff,
FileMinus2, FileMinus2,
FileType, FileType,
FileX, FileX,
@@ -34,7 +34,7 @@
onDiscardMany: (files: GitFileStatus[]) => void; onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void; onStash: (files: GitFileStatus[], label: string) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void; onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void; onStopTracking: (targets: string[], kind: "file" | "folder" | "selection") => void;
onPatch: (file: GitFileStatus, staged: boolean) => void; onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void; onStageAll: () => void;
onUnstageAll: () => void; onUnstageAll: () => void;
@@ -64,7 +64,8 @@
interface StatusContextTarget { interface StatusContextTarget {
lane: StatusLaneKind; lane: StatusLaneKind;
kind: "file" | "folder"; /** "selection" is a right-click on one row of a multi-selection. */
kind: "file" | "folder" | "selection";
label: string; label: string;
files: GitFileStatus[]; files: GitFileStatus[];
} }
@@ -228,9 +229,22 @@
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (files.length === 0) return; if (files.length === 0) return;
// Right-clicking a row that belongs to the current multi-selection acts on
// the whole selection, the same way the row buttons already do.
let targetKind: StatusContextTarget["kind"] = kind;
let targetFiles = files;
if (kind === "file" && files.length === 1 && isStatusSelected(files[0])) {
const laneFiles = selectedFiles().filter((file) => (lane === "unstaged" ? file.unstaged !== null : file.staged !== null));
if (laneFiles.length > 1) {
targetKind = "selection";
targetFiles = laneFiles;
}
}
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288)); statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174)); statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files }; statusContextTarget = { lane, kind: targetKind, label, files: targetFiles };
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!statusContextMenuElement) return; if (!statusContextMenuElement) return;
const bounds = statusContextMenuElement.getBoundingClientRect(); const bounds = statusContextMenuElement.getBoundingClientRect();
@@ -266,7 +280,7 @@
const target = statusContextTarget; const target = statusContextTarget;
if (!target) return; if (!target) return;
closeStatusContextMenu(); closeStatusContextMenu();
onStash(target.files, target.label); onStash(target.files, target.kind === "selection" ? "" : target.label);
} }
function isIgnoreableNewFile(file: GitFileStatus): boolean { function isIgnoreableNewFile(file: GitFileStatus): boolean {
@@ -305,7 +319,8 @@
const target = statusContextTarget; const target = statusContextTarget;
if (!target) return; if (!target) return;
closeStatusContextMenu(); closeStatusContextMenu();
onStopTracking(target.label, target.kind); const targets = target.kind === "selection" ? target.files.map((file) => file.path) : [target.label];
onStopTracking(targets, target.kind);
} }
function handleStatusWindowKeydown(event: KeyboardEvent) { function handleStatusWindowKeydown(event: KeyboardEvent) {
@@ -396,10 +411,10 @@
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles)); let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length); let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length); let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false); let statusContextCanIgnore = $derived(statusContextTarget?.kind !== "selection" && (statusContextTarget?.files.some(isIgnoreableNewFile) ?? false));
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false); let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : ""); let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : ""); let statusContextIgnoreFolder = $derived(statusContextTarget && statusContextTarget.kind !== "selection" ? statusContextFolder(statusContextTarget) : "");
$effect(() => { $effect(() => {
const validKeys = new Set(changedFiles.map(fileKey)); const validKeys = new Set(changedFiles.map(fileKey));
@@ -580,12 +595,18 @@
<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 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 if statusContextTarget.kind === "selection"}<CopyCheck 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" ? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : t("status.menuKindUnstagedFile")) : (statusContextTarget.kind === "folder" ? t("status.menuKindStagedFolder") : t("status.menuKindStagedFile"))}</span> <span class="status-context-kind">{statusContextTarget.lane === "unstaged"
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong> ? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : statusContextTarget.kind === "selection" ? t("status.menuKindUnstagedSelection") : t("status.menuKindUnstagedFile"))
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span> : (statusContextTarget.kind === "folder" ? t("status.menuKindStagedFolder") : statusContextTarget.kind === "selection" ? t("status.menuKindStagedSelection") : t("status.menuKindStagedFile"))}</span>
{#if statusContextTarget.kind === "selection"}
<strong>{t("status.menuFileCount", { count: statusContextTarget.files.length })}</strong>
{:else}
<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>
{/if}
</span> </span>
<span class="status-context-count" title={statusContextTarget.files.length === 1 ? t("status.menuFileCountOne") : t("status.menuFileCount", { count: statusContextTarget.files.length })}> <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}
@@ -596,14 +617,16 @@
{#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" ? (statusContextTarget.kind === "folder" ? t("status.menuStageFolder") : t("status.menuStageFile")) : (statusContextTarget.kind === "folder" ? t("status.menuUnstageFolder") : t("status.menuUnstageFile"))}</strong> <strong>{statusContextTarget.lane === "unstaged"
? (statusContextTarget.kind === "folder" ? t("status.menuStageFolder") : statusContextTarget.kind === "selection" ? t("status.menuStageSelection", { count: statusContextTarget.files.length }) : t("status.menuStageFile"))
: (statusContextTarget.kind === "folder" ? t("status.menuUnstageFolder") : statusContextTarget.kind === "selection" ? t("status.menuUnstageSelection", { count: statusContextTarget.files.length }) : t("status.menuUnstageFile"))}</strong>
<span>{statusContextTarget.lane === "unstaged" ? t("status.menuStageHint") : t("status.menuUnstageHint")}</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>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : t("status.menuStashFile")}</strong> <strong>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : statusContextTarget.kind === "selection" ? t("status.menuStashSelection", { count: statusContextTarget.files.length }) : t("status.menuStashFile")}</strong>
<span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span> <span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span>
</span> </span>
</button> </button>
@@ -616,7 +639,7 @@
{#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>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : t("status.menuStopTrackingFile")}</strong> <strong>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : statusContextTarget.kind === "selection" ? t("status.menuStopTrackingSelection", { count: statusContextTarget.files.length }) : t("status.menuStopTrackingFile")}</strong>
<span>{t("status.menuStopTrackingNote")}</span> <span>{t("status.menuStopTrackingNote")}</span>
</span> </span>
</button> </button>
+1 -1
View File
@@ -430,7 +430,7 @@
<ConfirmDialog <ConfirmDialog
request={removalConfirmRequest(pendingRemoval)} request={removalConfirmRequest(pendingRemoval)}
{isBusy} {isBusy}
onConfirm={(force) => { void confirmRemoval(force); }} onConfirm={(result) => { void confirmRemoval(result.checked); }}
onCancel={() => { pendingRemoval = null; forceRemoval = false; }} onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
/> />
{/if} {/if}
+15
View File
@@ -479,6 +479,21 @@ export const messages = {
"confirm.worktreeRemove.title": { en: "Remove {name}?", de: "{name} entfernen?" }, "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.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" }, "confirm.worktreeRemove.action": { en: "Remove worktree", de: "Worktree entfernen" },
// ── Stash from the changes context menu ────────────────────────────────────
"confirm.stashFiles.eyebrow": { en: "Save for later", de: "Für später sichern" },
"confirm.stashFiles.titleOne": { en: "Stash 1 file?", de: "1 Datei stashen?" },
"confirm.stashFiles.title": { en: "Stash {count} files?", de: "{count} Dateien stashen?" },
"confirm.stashFiles.message": { en: "These changes are saved to a stash and removed from your working tree.", de: "Diese Änderungen werden in einem Stash gesichert und aus dem Arbeitsverzeichnis entfernt." },
"confirm.stashFiles.inputLabel": { en: "Message (optional)", de: "Nachricht (optional)" },
"confirm.stashFiles.untracked": { en: "Include untracked files", de: "Unverfolgte Dateien einbeziehen" },
"confirm.stashFiles.untrackedNote": { en: "Files Git does not track yet are stashed as well.", de: "Dateien, die Git noch nicht verfolgt, werden mitgesichert." },
"status.menuKindUnstagedSelection": { en: "Unstaged selection", de: "Ungestagte Auswahl" },
"status.menuKindStagedSelection": { en: "Staged selection", de: "Gestagte Auswahl" },
"status.menuStageSelection": { en: "Stage {count} files", de: "{count} Dateien stagen" },
"status.menuUnstageSelection": { en: "Unstage {count} files", de: "{count} Dateien entstagen" },
"status.menuStashSelection": { en: "Stash {count} files", de: "{count} Dateien stashen" },
"status.menuStopTrackingSelection": { en: "Stop tracking {count} files", de: "{count} Dateien nicht mehr verfolgen" },
} as const; } as const;
export type MessageKey = keyof typeof messages; export type MessageKey = keyof typeof messages;