From acf8898b985d5d3be6beaee834094da691fa70e3 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 22:58:30 +0200 Subject: [PATCH] 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. --- src/App.svelte | 61 ++++++++++++++++++------ src/lib/components/ConfirmDialog.svelte | 58 ++++++++++++++++++---- src/lib/components/ExplorerPanel.svelte | 4 +- src/lib/components/StatusPanel.svelte | 53 ++++++++++++++------ src/lib/components/WorktreeDialog.svelte | 2 +- src/lib/messages.ts | 15 ++++++ 6 files changed, 152 insertions(+), 41 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index a20a3fd..41d45b2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -438,8 +438,9 @@ let externalToolsDetectionUnavailable = false; let errorMessage = ""; let operation = ""; + type ConfirmAnswer = { confirmed: boolean; value: string; checked: boolean }; let confirmDialogRequest: ConfirmRequest | null = null; - let confirmDialogResolve: ((confirmed: boolean) => void) | null = null; + let confirmDialogResolve: ((answer: ConfirmAnswer) => void) | null = null; let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; @@ -1289,15 +1290,20 @@ setLanguage(next); } - /** Show the confirmation dialog and resolve once the user answers. */ - function requestConfirmation(request: ConfirmRequest): Promise { - confirmDialogResolve?.(false); + /** Show the dialog and resolve with the answer, including input and checkbox. */ + function askConfirmation(request: ConfirmRequest): Promise { + confirmDialogResolve?.({ confirmed: false, value: "", checked: false }); confirmDialogRequest = request; - return new Promise((resolve) => { + return new Promise((resolve) => { confirmDialogResolve = resolve; }); } + /** Yes/no only, for the many confirmations that need nothing else. */ + async function requestConfirmation(request: ConfirmRequest): Promise { + return (await askConfirmation(request)).confirmed; + } + /** Confirmation shown before deleting a local or remote branch. */ function branchDeleteConfirmRequest(branch: GitBranchInfo, force: boolean): ConfirmRequest { 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; confirmDialogRequest = null; confirmDialogResolve = null; - resolve?.(confirmed); + resolve?.({ confirmed, value, checked }); } function applyThemePreference(next: AppTheme) { @@ -4797,8 +4803,34 @@ } async function stashStatusFiles(files: GitFileStatus[], label: string) { - const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`; - await saveStash(`Gitty: ${suffix}`, true, files); + if (files.length === 0) return; + 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) { @@ -4884,12 +4916,13 @@ }); } - async function stopTrackingTarget(target: string, kind: "file" | "folder") { - if (!activeRepoPath || !target) return; + async function stopTrackingTarget(targets: string[], kind: "file" | "folder" | "selection") { + const paths = targets.filter(Boolean); + if (!activeRepoPath || paths.length === 0) return; await runOperation(`Stopping tracking for ${kind}`, async () => { - applyStatus(await untrackPaths(activeRepoPath, [target])); + applyStatus(await untrackPaths(activeRepoPath, paths)); await refreshExplorerFiles(activeRepoPath); - trackEvent("git_paths_untracked", { kind }); + trackEvent("git_paths_untracked", { kind, paths: paths.length }); }); } @@ -6608,7 +6641,7 @@ {#if confirmDialogRequest} answerConfirmation(true)} + onConfirm={(result) => answerConfirmation(true, result.value, result.checked)} onCancel={() => answerConfirmation(false)} /> {/if} diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index 900a0e8..5ade56b 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -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(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 @@ {/if} + {#if request.input} + + {/if} + {#if request.checkbox}