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:
+47
-14
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user