import React, { useEffect, useMemo, useRef, useState, useCallback, } from "react"; import { useLockFn } from "ahooks"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { useTranslation } from "react-i18next"; import { importProfile, enhanceProfiles, deleteProfile, updateProfile, reorderProfile, createProfile, } from "@/services/cmds"; import { useSetLoadingCache } from "@/services/states"; import { closeAllConnections } from "@/services/api"; import { DialogRef } from "@/components/base"; import { ProfileViewer, ProfileViewerRef, } from "@/components/profile/profile-viewer"; import { ProfileItem } from "@/components/profile/profile-item"; import { useProfiles } from "@/hooks/use-profiles"; import { ConfigViewer } from "@/components/setting/mods/config-viewer"; import { throttle } from "lodash-es"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { useLocation } from "react-router-dom"; import { useListen } from "@/hooks/use-listen"; import { listen, TauriEvent } from "@tauri-apps/api/event"; import { showNotice } from "@/services/noticeService"; import { cn } from "@root/lib/utils"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { PlusCircle, RefreshCw, Zap, FileText, Loader2, } from "lucide-react"; import { SidebarTrigger } from "@/components/ui/sidebar"; const ProfilePage = () => { const { t } = useTranslation(); const location = useLocation(); const { addListener } = useListen(); const [url, setUrl] = useState(""); const [disabled, setDisabled] = useState(false); const [activatings, setActivatings] = useState([]); const [importLoading, setImportLoading] = useState(false); const [updateAllLoading, setUpdateAllLoading] = useState(false); const [enhanceLoading, setEnhanceLoading] = useState(false); const scrollerRef = useRef(null); const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { const currentScroller = scrollerRef.current; if (!currentScroller) return; const handleScroll = () => setIsScrolled(currentScroller.scrollTop > 5); currentScroller.addEventListener("scroll", handleScroll); return () => currentScroller.removeEventListener("scroll", handleScroll); }, []); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); let currentProfileFromLocation: string | undefined; if ( location.state && typeof location.state === "object" && location.state !== null ) { const stateAsObject = location.state as { current?: unknown }; if (typeof stateAsObject.current === "string") { currentProfileFromLocation = stateAsObject.current; } } const profilesHookData = useProfiles(); const profiles = profilesHookData.profiles || {}; const activateSelected = profilesHookData.activateSelected; const patchProfiles = profilesHookData.patchProfiles; const mutateProfiles = profilesHookData.mutateProfiles; const viewerRef = useRef(null); const configRef = useRef(null); const profileItems = useMemo(() => { const items = profiles && Array.isArray(profiles.items) ? profiles.items : []; const type1 = ["local", "remote"]; return items.filter((i) => i && type1.includes(i.type!)); }, [profiles]); const currentActivatings = () => { const currentProfileValue = profiles && typeof profiles.current === "string" ? profiles.current : ""; return [...new Set([currentProfileValue])].filter(Boolean); }; useEffect(() => { const handleFileDrop = async () => { const unlisten = await addListener( TauriEvent.DRAG_DROP, async (event: any) => { const paths = event.payload.paths; for (let file of paths) { if (!file.endsWith(".yaml") && !file.endsWith(".yml")) { showNotice("error", t("Only YAML Files Supported")); continue; } const item = { type: "local", name: file.split(/\\|\//).pop() ?? "New Profile", desc: "", url: "", option: { with_proxy: false, self_proxy: false }, } as IProfileItem; let data = await readTextFile(file); await createProfile(item, data); await mutateProfiles(); } }, ); return unlisten; }; const unsubscribe = handleFileDrop(); return () => { unsubscribe.then((cleanup) => cleanup()); }; }, [addListener, mutateProfiles, t]); const activateProfile = useCallback( async (profile: string, notifySuccess: boolean) => { const reset = setTimeout( () => setActivatings((prev) => [...prev, profile]), 100, ); try { const success = await patchProfiles({ current: profile }); closeAllConnections(); await activateSelected(); if (notifySuccess && success) { showNotice("success", t("Profile Switched"), 1000); } } catch (err: any) { showNotice("error", err?.message || err.toString(), 4000); } finally { clearTimeout(reset); setActivatings([]); } }, [patchProfiles, activateSelected, t], ); useEffect(() => { (async () => { if (currentProfileFromLocation) { await activateProfile(currentProfileFromLocation, false); } })(); }, [currentProfileFromLocation]); const onSelect = useLockFn( async (selectedProfileId: string, force: boolean) => { if (!force && selectedProfileId === profiles.current) return; await activateProfile(selectedProfileId, true); }, ); const onImport = useLockFn(async () => { if (!url) return; setImportLoading(true); setDisabled(true); try { await importProfile(url); showNotice("success", t("Profile Imported Successfully")); setUrl(""); mutateProfiles(); await onEnhance(false, false); } catch (err: any) { showNotice("info", t("Import failed, retrying with Clash proxy...")); try { await importProfile(url, { with_proxy: false, self_proxy: true }); showNotice("success", t("Profile Imported with Clash proxy")); setUrl(""); mutateProfiles(); await onEnhance(false, false); } catch (retryErr: any) { const retryErrmsg = retryErr?.message || retryErr.toString(); showNotice( "error", `${t("Import failed even with Clash proxy")}: ${retryErrmsg}`, ); } } finally { setDisabled(false); setImportLoading(false); } }); const onDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { await reorderProfile(active.id.toString(), over.id.toString()); mutateProfiles(); } }; const onEnhance = useLockFn( async (notifySuccess: boolean = true, showLoading: boolean = true) => { if (showLoading) setEnhanceLoading(true); setActivatings(currentActivatings()); try { await enhanceProfiles(); if (notifySuccess) { showNotice("success", t("Profile Reactivated"), 1000); } } catch (err: any) { showNotice("error", err.message || err.toString(), 3000); } finally { setActivatings([]); if (showLoading) setEnhanceLoading(false); } }, ); const onDelete = useLockFn(async (uid: string) => { const currentProfile = profiles.current === uid; try { setActivatings([...(currentProfile ? currentActivatings() : []), uid]); await deleteProfile(uid); mutateProfiles(); if (currentProfile) await onEnhance(false, false); } catch (err: any) { showNotice("error", err?.message || err.toString()); } finally { setActivatings([]); } }); const setLoadingCache = useSetLoadingCache(); const onUpdateAll = useLockFn(async () => { setUpdateAllLoading(true); const throttleMutate = throttle(mutateProfiles, 2000, { trailing: true }); const updateOne = async (uid: string) => { try { await updateProfile(uid); throttleMutate(); } catch (err: any) { console.error(`Update subscription ${uid} failed:`, err); } finally { setLoadingCache((cache) => ({ ...cache, [uid]: false })); } }; return new Promise((resolve) => { setLoadingCache((cache) => { const items = profileItems.filter( (e) => e.type === "remote" && !cache[e.uid], ); const change = Object.fromEntries(items.map((e) => [e.uid, true])); Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve); return { ...cache, ...change }; }); }).finally(() => setUpdateAllLoading(false)); }); const onCopyLink = async () => { const text = await readText(); if (text) setUrl(text); }; useEffect(() => { let unlistenPromise: Promise<() => void> | undefined; let timeoutId: ReturnType | undefined; const setupListener = async () => { unlistenPromise = listen("profile-changed", () => { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { mutateProfiles(); timeoutId = undefined; }, 300); }); }; setupListener(); return () => { if (timeoutId) clearTimeout(timeoutId); unlistenPromise?.then((unlisten) => unlisten()); }; }, [mutateProfiles]); return (

{t("Profiles")}

{t("New")}

{t("Update All Profiles")}

{t("Reactivate Profiles")}

{t("View Runtime Config")}

x.uid)}> {profileItems.map((item) => ( onSelect(item.uid, f)} onEdit={() => viewerRef.current?.edit(item)} onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { await onEnhance(false, false); } }} onDelete={() => onDelete(item.uid)} /> ))}
{ mutateProfiles(); if (isActivating) { await onEnhance(false, false); } }} />
); }; export default ProfilePage;