refactor: cleanup

This commit is contained in:
zhom
2026-04-08 12:48:42 +04:00
parent a0205aafa9
commit 3f1f11001e
17 changed files with 535 additions and 312 deletions
+26 -17
View File
@@ -77,11 +77,16 @@ export function CookieCopyDialog({
);
const [error, setError] = useState<string | null>(null);
// Never offer a selected profile as a source — you can't copy a profile's
// cookies onto itself, and including it here would leave the user in a
// dead-end state (source picked = target list empty = copy button disabled).
const eligibleSourceProfiles = useMemo(() => {
return profiles.filter(
(p) => p.browser === "wayfern" || p.browser === "camoufox",
(p) =>
!selectedProfiles.includes(p.id) &&
(p.browser === "wayfern" || p.browser === "camoufox"),
);
}, [profiles]);
}, [profiles, selectedProfiles]);
const targetProfiles = useMemo(() => {
return profiles.filter(
@@ -148,22 +153,21 @@ export function CookieCopyDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setSelection((prev) => {
const current = prev[domain];
const allSelected = current.allSelected;
if (allSelected) {
// `prev[domain]` is `undefined` for any domain not yet interacted with
// and after the user fully deselects it (toggleCookie deletes the
// entry on empty). Treat missing as "not selected".
if (prev[domain]?.allSelected) {
const newSelection = { ...prev };
delete newSelection[domain];
return newSelection;
} else {
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
}
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
});
},
[],
@@ -503,9 +507,13 @@ function DomainRow({
onToggleCookie,
onToggleExpand,
}: DomainRowProps) {
// `selection[domain.domain]` is `undefined` for domains the user hasn't
// touched yet (initial state after loading cookies is `{}`) and for any
// domain the user fully deselected (toggleCookie deletes the entry on
// empty). Default to "no cookies selected" instead of crashing.
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -540,7 +548,8 @@ function DomainRow({
{isExpanded && (
<div className="ml-8 pl-2 border-l space-y-1">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+9 -5
View File
@@ -309,8 +309,11 @@ export function CookieManagementDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setExportSelection((prev) => {
const current = prev[domain];
if (current.allSelected) {
// `prev[domain]` is `undefined` when the domain was previously fully
// deselected (entries are deleted on empty — see toggleCookie). Treat
// missing as "not selected" so re-enabling falls through to the add
// branch instead of crashing on `.allSelected`.
if (prev[domain]?.allSelected) {
const next = { ...prev };
delete next[domain];
return next;
@@ -592,8 +595,8 @@ function ExportDomainRow({
onToggleExpand,
}: ExportDomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -628,7 +631,8 @@ function ExportDomainRow({
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+14
View File
@@ -33,6 +33,7 @@ import {
ProfileBypassRulesDialog,
ProfileDnsBlocklistDialog,
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -937,6 +938,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -2680,6 +2683,9 @@ export function ProfilesDataTable({
onOpenDnsBlocklist={(profile) => {
setDnsBlocklistProfile(profile);
}}
onOpenLaunchHook={(profile) => {
setLaunchHookProfile(profile);
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
@@ -2770,6 +2776,14 @@ export function ProfilesDataTable({
profileId={dnsBlocklistProfile?.id ?? null}
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
/>
<ProfileLaunchHookDialog
isOpen={launchHookProfile !== null}
onClose={() => {
setLaunchHookProfile(null);
}}
profileId={launchHookProfile?.id ?? null}
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
/>
</>
);
}
+92 -49
View File
@@ -13,6 +13,7 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuLink,
LuPlus,
LuPuzzle,
LuRefreshCw,
@@ -66,6 +67,7 @@ interface ProfileInfoDialogProps {
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
onOpenLaunchHook?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
@@ -113,6 +115,7 @@ export function ProfileInfoDialog({
onAssignExtensionGroup,
onOpenBypassRules,
onOpenDnsBlocklist,
onOpenLaunchHook,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
@@ -128,8 +131,6 @@ export function ProfileInfoDialog({
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [launchHookValue, setLaunchHookValue] = React.useState("");
const [isSavingLaunchHook, setIsSavingLaunchHook] = React.useState(false);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -171,12 +172,6 @@ export function ProfileInfoDialog({
}
}, [isOpen]);
React.useEffect(() => {
if (isOpen) {
setLaunchHookValue(profile?.launch_hook ?? "");
}
}, [isOpen, profile?.launch_hook]);
if (!profile) return null;
const ProfileIcon = getProfileIcon(profile);
@@ -225,23 +220,14 @@ export function ProfileInfoDialog({
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
const trimmedLaunchHook = launchHookValue.trim();
const savedLaunchHook = profile.launch_hook ?? "";
const handleSaveLaunchHook = async () => {
setIsSavingLaunchHook(true);
try {
await invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: trimmedLaunchHook || null,
});
} catch (error) {
console.error("Failed to update launch hook:", error);
} finally {
setIsSavingLaunchHook(false);
}
};
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
// settings UI — inputs, toggles, save buttons — directly in this dialog's
// settings tab. Each setting belongs in its own focused dialog (see
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
icon: React.ReactNode;
label: string;
@@ -360,6 +346,14 @@ export function ProfileInfoDialog({
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
},
hidden: !onOpenLaunchHook,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -575,31 +569,6 @@ export function ProfileInfoDialog({
<TabsContent value="settings">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
<div className="flex flex-col gap-3 py-1">
<div className="rounded-md bg-muted/50 border px-3 py-3">
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.label")}
</p>
<div className="flex gap-2 mt-2">
<Input
value={launchHookValue}
onChange={(e) => {
setLaunchHookValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSavingLaunchHook}
/>
<Button
onClick={() => void handleSaveLaunchHook()}
disabled={
isSavingLaunchHook ||
trimmedLaunchHook === savedLaunchHook
}
>
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="flex flex-col">
{visibleActions.map((action) => (
<button
@@ -639,6 +608,80 @@ export function ProfileInfoDialog({
);
}
interface ProfileLaunchHookDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
currentLaunchHook: string | null;
}
export function ProfileLaunchHookDialog({
isOpen,
onClose,
profileId,
currentLaunchHook,
}: ProfileLaunchHookDialogProps) {
const { t } = useTranslation();
const [value, setValue] = React.useState(currentLaunchHook ?? "");
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setValue(currentLaunchHook ?? "");
}
}, [isOpen, currentLaunchHook]);
const trimmed = value.trim();
const saved = currentLaunchHook ?? "";
const isDirty = trimmed !== saved;
const handleSave = async () => {
if (!profileId) return;
setIsSaving(true);
try {
await invoke("update_profile_launch_hook", {
profileId,
launchHook: trimmed || null,
});
onClose();
} catch (err) {
console.error("Failed to update launch hook:", err);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.description")}
</p>
<Input
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSaving}
/>
<DialogFooter>
<Button
onClick={() => void handleSave()}
disabled={isSaving || !isDirty}
className="w-full"
>
{t("common.buttons.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ProfileDnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Sync Settings",
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
"copyCookiesToProfile": "Copy Cookies to Profile",
"launchHook": "Launch Hook URL"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
@@ -789,7 +790,9 @@
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"launchHook": {
"title": "Launch Hook URL",
"label": "Launch Hook URL",
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configuración de Sincronización",
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
"copyCookiesToProfile": "Copiar Cookies al Perfil",
"launchHook": "URL del hook de inicio"
},
"synchronizer": {
"launchWithSync": "Lanzar con Sincronizador",
@@ -789,7 +790,9 @@
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"launchHook": {
"title": "URL del hook de inicio",
"label": "URL del hook de inicio",
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Paramètres de Synchronisation",
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
"launchHook": "URL du hook de lancement"
},
"synchronizer": {
"launchWithSync": "Lancer avec le synchroniseur",
@@ -789,7 +790,9 @@
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"launchHook": {
"title": "URL du hook de lancement",
"label": "URL du hook de lancement",
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "同期設定",
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
"launchHook": "起動フックURL"
},
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
@@ -789,7 +790,9 @@
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"launchHook": {
"title": "起動フックURL",
"label": "起動フックURL",
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエストを送信します。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configurações de Sincronização",
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
"launchHook": "URL do hook de inicialização"
},
"synchronizer": {
"launchWithSync": "Iniciar com Sincronizador",
@@ -789,7 +790,9 @@
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"launchHook": {
"title": "URL do hook de inicialização",
"label": "URL do hook de inicialização",
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Настройки синхронизации",
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
"copyCookiesToProfile": "Копировать Cookie в профиль",
"launchHook": "URL хука запуска"
},
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
@@ -789,7 +790,9 @@
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"launchHook": {
"title": "URL хука запуска",
"label": "URL хука запуска",
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "同步设置",
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
"copyCookiesToProfile": "复制 Cookies 到配置文件",
"launchHook": "启动钩子 URL"
},
"synchronizer": {
"launchWithSync": "使用同步器启动",
@@ -789,7 +790,9 @@
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"launchHook": {
"title": "启动钩子 URL",
"label": "启动钩子 URL",
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {