From 7ff709f565456f8a5fa5d8e25a1699667a5a76e2 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 29 Sep 2026 17:08:33 +0200 Subject: [PATCH] [client] Keep the delete-profile dialog open until the delete finishes (#7752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Keep the delete-profile dialog open until the delete finishes Confirming a profile deletion closed the dialog straight away and left the daemon call running in the background. A slow delete then looked like nothing had happened: the dialog was gone, the profile was still listed, and the row only disappeared whenever the refresh landed. The confirm dialog now owns the action. It stays open with the confirm button spinning, closes once the call resolves, and surfaces a failure after it is gone rather than behind it. Nothing on the daemon path carries a deadline, so the wait is bounded in the dialog instead: Cancel comes back after five seconds and the wait is abandoned at thirty, which keeps a hung daemon from trapping the user in a modal that cannot be dismissed. A loading button keeps its own variant colours rather than the disabled skin, which dimmed the spinner to grey on the danger variant, and blocks input through aria-disabled and a click guard instead. * [client] Match the theme and anonymize pickers to the language switcher * [client] Settle a confirm dialog only from the run that opened it Cancelling at the stall point leaves the action running, and the provider is mounted once: take() read whichever settler the ref held when the stale run finally finished. Open another prompt in the meantime and that run answered it — a hung delete that later resolved confirmed a profile switch nobody accepted, and its timeout closed the new dialog with an unrelated error. Each run now remembers the settler it was dispatched for and settles only while the ref still points at it. A late arrival finds a stranger there and answers nothing. * [client] Add the shared Select the theme and anonymize pickers use The picker rework landed without the component both pickers import, so the branch did not compile. Add it, and name it for what it is: a select of a few options, with nothing settings-specific about it, so it sits with the other input controls rather than under a name that discourages reuse. * [client] Drop the Select header comment * [client] Fix cubic comments * [client] Name the Select trigger with the option it is showing * [client] Name the language trigger with the language it is showing * [client] Hold the confirm dialog for 15s before offering cancel --- .../frontend/src/components/DropdownMenu.tsx | 16 +- .../src/components/LanguagePicker.tsx | 6 +- .../frontend/src/components/ThemePicker.tsx | 82 ++------- .../src/components/buttons/Button.tsx | 8 +- .../src/components/dialog/ConfirmModal.tsx | 9 +- .../frontend/src/components/inputs/Select.tsx | 99 ++++++++++ .../frontend/src/contexts/DialogContext.tsx | 66 ++++++- .../src/modules/profiles/ProfilesTab.tsx | 19 +- .../src/modules/settings/SettingsAccent.tsx | 171 ++++++++++++++++-- .../settings/SettingsTroubleshooting.tsx | 74 +++----- 10 files changed, 397 insertions(+), 153 deletions(-) create mode 100644 client/ui/frontend/src/components/inputs/Select.tsx diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index 8cedcea03..aa3ced05f 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -1,6 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { cva } from "class-variance-authority"; -import { Check, ChevronRight, Circle } from "lucide-react"; +import { Check, ChevronRight } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/cn"; @@ -159,19 +159,23 @@ const DropdownMenuRadioItem = React.forwardRef< - + {children} + - + - {children} )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 35ef7d5b5..d0a95906f 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -89,7 +89,11 @@ export function LanguagePicker() { tabIndex={0} disabled={busy || languages.length === 0} onKeyDown={handleTriggerKeyDown} - aria-label={t("settings.general.language.label")} + aria-label={ + current + ? `${t("settings.general.language.label")}: ${labelFor(current)}` + : t("settings.general.language.label") + } aria-haspopup={"listbox"} aria-expanded={open} className={cn( diff --git a/client/ui/frontend/src/components/ThemePicker.tsx b/client/ui/frontend/src/components/ThemePicker.tsx index c3dc70d1e..3acb11a99 100644 --- a/client/ui/frontend/src/components/ThemePicker.tsx +++ b/client/ui/frontend/src/components/ThemePicker.tsx @@ -1,18 +1,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/DropdownMenu"; +import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; +import { Select } from "@/components/inputs/Select"; import { HelpText } from "@/components/typography/HelpText"; import { Label } from "@/components/typography/Label"; import { useTheme, type ThemePreference } from "@/contexts/ThemeContext"; -import { useFocusVisible } from "@/hooks/useFocusVisible"; -import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [ @@ -25,16 +17,12 @@ export function ThemePicker() { const { t } = useTranslation(); const { theme, setTheme } = useTheme(); const [busy, setBusy] = useState(false); - const isFocusVisible = useFocusVisible(); - const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0]; - const CurrentIcon = current.icon; - - const select = async (value: string) => { + const select = async (value: ThemePreference) => { if (busy || value === theme) return; setBusy(true); try { - await setTheme(value as ThemePreference); + await setTheme(value); } catch (e) { await errorDialog({ Title: t("settings.error.saveTitle"), @@ -52,57 +40,17 @@ export function ThemePicker() { {t("settings.general.theme.help")}
- - - - - - void select(v)}> - {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( - - - {t(labelKey)} - - ))} - - - + ({ + value, + icon, + label: t(`settings.troubleshooting.anonymize.${value}`), + }))} + onChange={setAnonymizeLevel} + ariaLabel={t("settings.troubleshooting.anonymize.label")} + />