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
+49 -9
View File
@@ -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; }