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:
@@ -19,8 +19,10 @@
|
||||
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 };
|
||||
/** Optional opt-in, e.g. "delete anyway" (required) or "include untracked". */
|
||||
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. */
|
||||
danger?: boolean;
|
||||
}
|
||||
@@ -28,8 +30,8 @@
|
||||
interface Props {
|
||||
request: ConfirmRequest;
|
||||
isBusy?: boolean;
|
||||
/** `checked` is the state of the optional checkbox. */
|
||||
onConfirm: (checked: boolean) => void;
|
||||
/** Carries the state of the optional checkbox and input. */
|
||||
onConfirm: (result: { checked: boolean; value: string }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -42,18 +44,28 @@
|
||||
let danger = $derived(request.danger !== false);
|
||||
let items = $derived(request.items ?? []);
|
||||
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(() => {
|
||||
// Reset the opt-in whenever a different confirmation is shown.
|
||||
// Start from the defaults again whenever a different confirmation is shown.
|
||||
request.title;
|
||||
checked = false;
|
||||
checked = request.checkbox?.defaultChecked ?? false;
|
||||
value = request.input?.value ?? "";
|
||||
});
|
||||
|
||||
$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) {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
@@ -116,6 +128,22 @@
|
||||
</ul>
|
||||
{/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}
|
||||
<label class="confirm-check">
|
||||
<input type="checkbox" bind:checked disabled={isBusy} />
|
||||
@@ -140,7 +168,7 @@
|
||||
bind:this={confirmButton}
|
||||
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
|
||||
type="button"
|
||||
onclick={() => onConfirm(checked)}
|
||||
onclick={submit}
|
||||
disabled={isBusy || blocked}
|
||||
>
|
||||
{#if isBusy}
|
||||
@@ -206,6 +234,13 @@
|
||||
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||
}
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -217,6 +252,11 @@
|
||||
background: rgba(255, 90, 103, 0.05);
|
||||
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 span { display: grid; gap: 2px; min-width: 0; }
|
||||
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
onStopTracking: (targets: string[], kind: "file" | "folder") => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
@@ -268,7 +268,7 @@
|
||||
const node = contextNode;
|
||||
if (!node) return;
|
||||
closeFileContextMenu();
|
||||
onStopTracking(node.path, node.kind);
|
||||
onStopTracking([node.path], node.kind);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FileDiff,
|
||||
CopyCheck, FileDiff,
|
||||
FileMinus2,
|
||||
FileType,
|
||||
FileX,
|
||||
@@ -34,7 +34,7 @@
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onStash: (files: GitFileStatus[], label: string) => 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;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -64,7 +64,8 @@
|
||||
|
||||
interface StatusContextTarget {
|
||||
lane: StatusLaneKind;
|
||||
kind: "file" | "folder";
|
||||
/** "selection" is a right-click on one row of a multi-selection. */
|
||||
kind: "file" | "folder" | "selection";
|
||||
label: string;
|
||||
files: GitFileStatus[];
|
||||
}
|
||||
@@ -228,9 +229,22 @@
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||
statusContextTarget = { lane, kind, label, files };
|
||||
statusContextTarget = { lane, kind: targetKind, label, files: targetFiles };
|
||||
requestAnimationFrame(() => {
|
||||
if (!statusContextMenuElement) return;
|
||||
const bounds = statusContextMenuElement.getBoundingClientRect();
|
||||
@@ -266,7 +280,7 @@
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStash(target.files, target.label);
|
||||
onStash(target.files, target.kind === "selection" ? "" : target.label);
|
||||
}
|
||||
|
||||
function isIgnoreableNewFile(file: GitFileStatus): boolean {
|
||||
@@ -305,7 +319,8 @@
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
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) {
|
||||
@@ -396,10 +411,10 @@
|
||||
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 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 statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
|
||||
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
|
||||
let statusContextIgnoreFolder = $derived(statusContextTarget && statusContextTarget.kind !== "selection" ? statusContextFolder(statusContextTarget) : "");
|
||||
|
||||
$effect(() => {
|
||||
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 class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else if statusContextTarget.kind === "selection"}<CopyCheck size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
</span>
|
||||
<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>
|
||||
<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-kind">{statusContextTarget.lane === "unstaged"
|
||||
? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : statusContextTarget.kind === "selection" ? t("status.menuKindUnstagedSelection") : t("status.menuKindUnstagedFile"))
|
||||
: (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 class="status-context-count" title={statusContextTarget.files.length === 1 ? t("status.menuFileCountOne") : t("status.menuFileCount", { count: statusContextTarget.files.length })}>
|
||||
{statusContextTarget.files.length}
|
||||
@@ -596,14 +617,16 @@
|
||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||
</span>
|
||||
<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>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>{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>
|
||||
</button>
|
||||
@@ -616,7 +639,7 @@
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
<ConfirmDialog
|
||||
request={removalConfirmRequest(pendingRemoval)}
|
||||
{isBusy}
|
||||
onConfirm={(force) => { void confirmRemoval(force); }}
|
||||
onConfirm={(result) => { void confirmRemoval(result.checked); }}
|
||||
onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -479,6 +479,21 @@ export const messages = {
|
||||
"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" },
|
||||
|
||||
// ── 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;
|
||||
|
||||
export type MessageKey = keyof typeof messages;
|
||||
|
||||
Reference in New Issue
Block a user