import { useEffect, useMemo, useState } from "react"; import { useLockFn } from "ahooks"; import yaml from "js-yaml"; import { useTranslation } from "react-i18next"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Virtuoso } from "react-virtuoso"; import MonacoEditor from "react-monaco-editor"; import { useForm } from "react-hook-form"; import { getNetworkInterfaces, readProfileFile, saveProfileFile, } from "@/services/cmds"; import getSystem from "@/utils/get-system"; import { useThemeMode } from "@/services/states"; import { BaseSearchBox } from "../base/base-search-box"; import { showNotice } from "@/services/noticeService"; import { cn } from "@root/lib/utils"; // --- Компоненты shadcn/ui и иконки --- import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Badge } from "@/components/ui/badge"; import { Form, FormControl, FormField, FormItem, FormLabel, FormDescription } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Check, ChevronsUpDown, GripVertical, Trash2, Undo2, ArrowDownToLine, ArrowUpToLine, } from "lucide-react"; // --- Вспомогательные функции, константы и валидаторы --- const portValidator = (value: string): boolean => /^(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/.test(value); const ipv4CIDRValidator = (value: string): boolean => /^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\/(?:[12]?[0-9]|3[0-2]))?$/.test(value); const ipv6CIDRValidator = (value: string): boolean => /^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(value); const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; interface Props { proxiesUid: string; mergeUid: string; profileUid: string; property: string; open: boolean; onClose: () => void; onSave?: (prev?: string, curr?: string) => void; } // --- Новый компонент Combobox (одиночный выбор) --- const Combobox = ({ options, value, onSelect, placeholder }: { options: string[], value: string, onSelect: (value: string) => void, placeholder?: string }) => { const [open, setOpen] = useState(false); return ( No results found. {options.map((option) => ( { onSelect(options.find(opt => opt.toLowerCase() === currentValue) || ''); setOpen(false); }}> {option} ))} ); }; // --- Новый компонент MultiSelectCombobox (множественный выбор) --- const MultiSelectCombobox = ({ options, value, onChange, placeholder }: { options: string[], value: string[], onChange: (value: string[]) => void, placeholder?: string }) => { const [open, setOpen] = useState(false); const selectedSet = new Set(value); const handleSelect = (currentValue: string) => { const newSet = new Set(selectedSet); if (newSet.has(currentValue)) { newSet.delete(currentValue); } else { newSet.add(currentValue); } onChange(Array.from(newSet)); }; return ( No results found. {options.map((option) => ( handleSelect(option)} className="cursor-pointer"> {option} ))} ); }; // --- Новый компонент для элемента списка групп --- const EditorGroupItem = ({ type, group, onDelete, id }: { type: string, group: IProxyGroupConfig, onDelete: () => void, id: string }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 100 : undefined }; const isDelete = type === 'delete'; return (

{group.name}

) }; export const GroupsEditorViewer = (props: Props) => { const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = props; const { t } = useTranslation(); const themeMode = useThemeMode(); const [prevData, setPrevData] = useState(""); const [currData, setCurrData] = useState(""); const [visualization, setVisualization] = useState(true); const [match, setMatch] = useState(() => (_: string) => true); const [interfaceNameList, setInterfaceNameList] = useState([]); const form = useForm({ defaultValues: { type: "select", name: "", interval: 300, timeout: 5000, "max-failed-times": 5, lazy: true }, }); const { control, watch, handleSubmit, getValues } = form; const [groupList, setGroupList] = useState([]); const [proxyPolicyList, setProxyPolicyList] = useState([]); const [proxyProviderList, setProxyProviderList] = useState([]); const [prependSeq, setPrependSeq] = useState([]); const [appendSeq, setAppendSeq] = useState([]); const [deleteSeq, setDeleteSeq] = useState([]); const filteredPrependSeq = useMemo(() => prependSeq.filter((group) => match(group.name)), [prependSeq, match]); const filteredGroupList = useMemo(() => groupList.filter((group) => match(group.name)), [groupList, match]); const filteredAppendSeq = useMemo(() => appendSeq.filter((group) => match(group.name)), [appendSeq, match]); const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); const reorder = (list: IProxyGroupConfig[], startIndex: number, endIndex: number) => { const result = Array.from(list); const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); return result; }; const onPrependDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { let activeIndex = 0; let overIndex = 0; prependSeq.forEach((item, index) => { if (item.name === active.id) activeIndex = index; if (item.name === over.id) overIndex = index; }); setPrependSeq(reorder(prependSeq, activeIndex, overIndex)); } }; const onAppendDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { let activeIndex = 0; let overIndex = 0; appendSeq.forEach((item, index) => { if (item.name === active.id) activeIndex = index; if (item.name === over.id) overIndex = index; }); setAppendSeq(reorder(appendSeq, activeIndex, overIndex)); } }; const fetchContent = async () => { try { let data = await readProfileFile(property); let obj = yaml.load(data) as ISeqProfileConfig | null; setPrependSeq(obj?.prepend || []); setAppendSeq(obj?.append || []); setDeleteSeq(obj?.delete || []); setPrevData(data); setCurrData(data); } catch (error) { console.error("Failed to fetch or parse content:", error); } }; useEffect(() => { if (currData === "" || !visualization) return; try { let obj = yaml.load(currData) as { prepend: [], append: [], delete: [] } | null; setPrependSeq(obj?.prepend || []); setAppendSeq(obj?.append || []); setDeleteSeq(obj?.delete || []); } catch (e) { /* Ignore parsing errors while typing */ } }, [visualization, currData]); useEffect(() => { if (prependSeq && appendSeq && deleteSeq && visualization) { const serialize = () => { try { setCurrData(yaml.dump({ prepend: prependSeq, append: appendSeq, delete: deleteSeq }, { forceQuotes: true })); } catch (e: any) { showNotice("error", e?.message || e?.toString() || "YAML dump error"); } }; if (window.requestIdleCallback) { window.requestIdleCallback(serialize); } else { setTimeout(serialize, 0); } } }, [prependSeq, appendSeq, deleteSeq, visualization]); const fetchProxyPolicy = async () => { try { let data = await readProfileFile(profileUid); let proxiesData = await readProfileFile(proxiesUid); let originGroupsObj = yaml.load(data) as { "proxy-groups": IProxyGroupConfig[] } | null; let originProxiesObj = yaml.load(data) as { proxies: [] } | null; let originProxies = originProxiesObj?.proxies || []; let moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; let morePrependProxies = moreProxiesObj?.prepend || []; let moreAppendProxies = moreProxiesObj?.append || []; let moreDeleteProxies = moreProxiesObj?.delete || ([] as string[] | { name: string }[]); let proxies = morePrependProxies.concat(originProxies.filter((proxy: any) => !moreDeleteProxies.some((del: any) => (del.name || del) === proxy.name)), moreAppendProxies); setProxyPolicyList(Array.from(new Set(builtinProxyPolicies.concat( prependSeq.map((group) => group.name), originGroupsObj?.["proxy-groups"].map((group) => group.name).filter((name) => !deleteSeq.includes(name)) || [], appendSeq.map((group) => group.name), proxies.map((proxy: any) => proxy.name), )))); } catch(error) { console.error("Failed to fetch proxy policy:", error) } }; const fetchProfile = async () => { try { let data = await readProfileFile(profileUid); let mergeData = await readProfileFile(mergeUid); let globalMergeData = await readProfileFile("Merge"); let originGroupsObj = yaml.load(data) as { "proxy-groups": IProxyGroupConfig[] } | null; let originProviderObj = yaml.load(data) as { "proxy-providers": {} } | null; let originProvider = originProviderObj?.["proxy-providers"] || {}; let moreProviderObj = yaml.load(mergeData) as { "proxy-providers": {} } | null; let moreProvider = moreProviderObj?.["proxy-providers"] || {}; let globalProviderObj = yaml.load(globalMergeData) as { "proxy-providers": {} } | null; let globalProvider = globalProviderObj?.["proxy-providers"] || {}; let provider = { ...originProvider, ...moreProvider, ...globalProvider }; setProxyProviderList(Object.keys(provider)); setGroupList(originGroupsObj?.["proxy-groups"] || []); } catch(error) { console.error("Failed to fetch profile:", error) } }; const getInterfaceNameList = async () => { try { let list = await getNetworkInterfaces(); setInterfaceNameList(list); } catch (error) { console.error("Failed to get network interfaces:", error) } }; useEffect(() => { fetchProxyPolicy(); }, [prependSeq, appendSeq, deleteSeq]); useEffect(() => { if (open) { fetchContent(); fetchProxyPolicy(); fetchProfile(); getInterfaceNameList(); } }, [open]); const validateGroup = () => { let group = getValues(); if (group.name === "") { throw new Error(t("Group Name Required")); } }; const handleSave = useLockFn(async () => { try { await saveProfileFile(property, currData); showNotice("success", t("Saved Successfully")); onSave?.(prevData, currData); onClose(); } catch (err: any) { showNotice("error", err.toString()); } }); const groupType = watch("type"); return (
{t("Edit Groups")}
{visualization ? (
{/* Левая панель: Конструктор групп */}

Constructor

({t("Group Type")})}/> ({t("Group Name")})}/> ({t("Proxy Group Icon")})}/> ({t("Use Proxies")})}/> ({t("Use Provider")})}/> {(groupType === "url-test" || groupType === "fallback") && <> ({t("Health Check Url")})}/> ({t("Interval")}
field.onChange(parseInt(e.target.value, 10) || 0)}/>{t("seconds")}
)}/> ({t("Timeout")}
field.onChange(parseInt(e.target.value, 10) || 0)}/>{t("millis")}
)}/> ({t("Max Failed Times")} field.onChange(parseInt(e.target.value, 10) || 0)}/>)}/> } ({t("Interface Name")})}/> ({t("Routing Mark")} field.onChange(parseInt(e.target.value, 10) || 0)}/>)}/> {(groupType === "url-test" || groupType === "fallback" || groupType === "load-balance") && <> ({t("Lazy")})} /> ({t("Disable UDP")})} /> } ({t("Hidden")})} />
setMatch(() => matcher)} />
0 ? 1 : 0) + (filteredAppendSeq.length > 0 ? 1 : 0)} itemContent={(index) => { let shift = filteredPrependSeq.length > 0 ? 1 : 0; if (filteredPrependSeq.length > 0 && index === 0) { return ( x.name)}>{filteredPrependSeq.map((item) => ( setPrependSeq(prependSeq.filter(v => v.name !== item.name))} />))}); } else if (index < filteredGroupList.length + shift) { const newIndex = index - shift; const currentGroup = filteredGroupList[newIndex]; return ( { if (deleteSeq.includes(currentGroup.name)) { setDeleteSeq(deleteSeq.filter(v => v !== currentGroup.name)); } else { setDeleteSeq((prev) => [...prev, currentGroup.name]); }}} />); } else { return ( x.name)}>{filteredAppendSeq.map((item) => ( setAppendSeq(appendSeq.filter(v => v.name !== item.name))} />))}); } }} />
) : (
= 1500 }, mouseWheelZoom: true, quickSuggestions: { strings: true, comments: true, other: true }, padding: { top: 16 }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`, fontLigatures: false, smoothScrolling: true }} onChange={(value) => setCurrData(value)} />
)}
); };