diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index b03577a34..98085429e 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -11,6 +11,19 @@ type Props = { onCreate: (name: string) => void; }; +// Mirror of the daemon's profilemanager.sanitizeProfileName rule +// (client/internal/profilemanager/profilemanager.go): only letters, digits, +// `_` and `-` survive on the Go side. We additionally lowercase and convert +// spaces to `-` so what the user sees in the input is exactly what the +// daemon will store — otherwise the daemon silently sanitizes ("my profile" +// → "myprofile") while the UI keeps the raw name in flight, which spawns a +// ghost row and breaks subsequent delete. +const sanitizeProfileInput = (value: string): string => + value + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9_-]/g, ""); + export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => { const { t } = useTranslation(); const [name, setName] = useState(""); @@ -26,18 +39,18 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => const handleSubmit = (e: FormEvent) => { e.preventDefault(); - const trimmed = name.trim(); - if (trimmed.length === 0) { + const sanitized = sanitizeProfileInput(name); + if (sanitized.length === 0) { setError(t("profile.dialog.required")); inputRef.current?.focus(); return; } - onCreate(trimmed); + onCreate(sanitized); onOpenChange(false); }; const handleChange = (value: string) => { - setName(value); + setName(sanitizeProfileInput(value)); if (error) setError(null); }; @@ -60,6 +73,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => value={name} onChange={(e) => handleChange(e.target.value)} error={error ?? undefined} + maxLength={64} + spellCheck={false} + autoComplete="off" + autoCapitalize="off" /> diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index 4dc1a4eaf..20d6d4c66 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { errorDialog, warningDialog } from "@/lib/dialogs.ts"; -import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react"; +import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react"; import type { Profile } from "@bindings/services/models.js"; import { Badge } from "@/components/Badge"; import { Button } from "@/components/buttons/Button"; @@ -31,6 +31,20 @@ export function ProfilesTab() { const [newOpen, setNewOpen] = useState(false); const [busy, setBusy] = useState(false); + const tabRootRef = useRef(null); + + // After a successful switch we want to bring the user back to the top of + // the tab — the table re-sorts the new active profile to the row 0 and a + // user who scrolled to find a target down the list would otherwise lose + // visual anchoring. Settings is hosted inside a Radix ScrollArea so we + // walk up to the viewport (it owns the actual overflow) instead of + // `window.scrollTo`, which is a no-op here. + const scrollTabToTop = () => { + const el = tabRootRef.current?.closest( + "[data-radix-scroll-area-viewport]", + ); + el?.scrollTo({ top: 0, behavior: "smooth" }); + }; const sorted = [...profiles].sort((a, b) => { if (a.name === activeProfile) return -1; @@ -53,6 +67,22 @@ export function ProfilesTab() { } }; + const handleSwitch = async (name: string) => { + const cancelLabel = i18next.t("common.cancel"); + const confirmLabel = i18next.t("profile.switch.confirm"); + const result = await warningDialog({ + Title: i18next.t("profile.switch.title"), + Message: i18next.t("profile.switch.message", { name }), + Buttons: [ + { Label: cancelLabel, IsCancel: true }, + { Label: confirmLabel, IsDefault: true }, + ], + }); + if (result !== confirmLabel) return; + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name)); + scrollTabToTop(); + }; + const handleDeregister = async (name: string) => { const cancelLabel = i18next.t("common.cancel"); const confirmLabel = i18next.t("profile.deregister.confirm"); @@ -97,7 +127,7 @@ export function ProfilesTab() { }; return ( - <> +
{t("settings.profiles.intro")} @@ -113,6 +143,7 @@ export function ProfilesTab() { key={profile.name} profile={profile} isActive={profile.name === activeProfile} + onSwitch={() => handleSwitch(profile.name)} onDeregister={() => handleDeregister(profile.name)} onDelete={() => handleDelete(profile.name)} /> @@ -146,18 +177,19 @@ export function ProfilesTab() { - +
); } type ProfileRowProps = { profile: Profile; isActive: boolean; + onSwitch: () => void; onDeregister: () => void; onDelete: () => void; }; -const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => { +const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => { const { t } = useTranslation(); const Icon = pickProfileIcon(profile.name) ?? UserCircle; const showEmail = !!profile.email; @@ -192,8 +224,11 @@ const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowPro @@ -222,14 +257,31 @@ const TruncatedEmail = ({ email }: { email: string }) => { }; type RowActionsProps = { + canSwitch: boolean; canDeregister: boolean; - canDelete: boolean; + isDefault: boolean; + isActive: boolean; + onSwitch: () => void; onDeregister: () => void; onDelete: () => void; }; -const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => { +const RowActions = ({ + canSwitch, + canDeregister, + isDefault, + isActive, + onSwitch, + onDeregister, + onDelete, +}: RowActionsProps) => { const { t } = useTranslation(); + const deleteDisabled = isDefault || isActive; + const deleteLabel = isDefault + ? t("profile.delete.disabledDefault") + : isActive + ? t("profile.delete.disabledActive") + : t("profile.selector.delete"); return (
); @@ -257,6 +315,8 @@ type ActionIconButtonProps = { /** When true the button still occupies space (preserves row layout) * but is invisible and non-interactive. */ hidden?: boolean; + /** When true the button is visible but non-interactive (greyed out). */ + disabled?: boolean; }; const ActionIconButton = ({ @@ -265,13 +325,15 @@ const ActionIconButton = ({ onClick, variant = "default", hidden = false, + disabled = false, }: ActionIconButtonProps) => { const button = (