"use client"; import { type ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, type RowSelectionState, type SortingState, useReactTable, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { LuCheck, LuChevronDown, LuChevronUp, LuCookie, LuInfo, LuLock, LuPlay, LuPuzzle, LuSquare, LuTrash2, LuTriangleAlert, LuUsers, } from "react-icons/lu"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { ProfileBypassRulesDialog, ProfileDnsBlocklistDialog, ProfileInfoDialog, ProfileLaunchHookDialog, } from "@/components/profile-info-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useBrowserState } from "@/hooks/use-browser-state"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useScrollFade } from "@/hooks/use-scroll-fade"; import { useTableSorting } from "@/hooks/use-table-sorting"; import { useTeamLocks } from "@/hooks/use-team-locks"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserDisplayName, getOSDisplayName, getProfileIcon, isCrossOsProfile, } from "@/lib/browser-utils"; import { formatRelativeTime } from "@/lib/flag-utils"; import { trimName } from "@/lib/name-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, ExtensionGroup, LocationItem, ProxyCheckResult, StoredProxy, SyncSessionInfo, TrafficSnapshot, VpnConfig, } from "@/types"; import { BandwidthMiniChart } from "./bandwidth-mini-chart"; import { DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection, } from "./data-table-action-bar"; import MultipleSelector, { type Option } from "./multiple-selector"; import { ProxyCheckButton } from "./proxy-check-button"; import { TrafficDetailsDialog } from "./traffic-details-dialog"; import { Input } from "./ui/input"; import { RippleButton } from "./ui/ripple"; // Stable table meta type to pass volatile state/handlers into TanStack Table without // causing column definitions to be recreated on every render. interface TableMeta { t: (key: string, options?: Record) => string; selectedProfiles: string[]; selectableCount: number; showCheckboxes: boolean; isClient: boolean; runningProfiles: Set; launchingProfiles: Set; stoppingProfiles: Set; isUpdating: (browser: string) => boolean; browserState: ReturnType; // Tags editor state tagsOverrides: Record; allTags: string[]; openTagsEditorFor: string | null; setAllTags: React.Dispatch>; setOpenTagsEditorFor: React.Dispatch>; setTagsOverrides: React.Dispatch< React.SetStateAction> >; // Note editor state noteOverrides: Record; openNoteEditorFor: string | null; setOpenNoteEditorFor: React.Dispatch>; setNoteOverrides: React.Dispatch< React.SetStateAction> >; // Proxy selector state openProxySelectorFor: string | null; setOpenProxySelectorFor: React.Dispatch>; proxyOverrides: Record; storedProxies: StoredProxy[]; handleProxySelection: ( profileId: string, proxyId: string | null, ) => void | Promise; checkingProfileId: string | null; proxyCheckResults: Record; // VPN selector state vpnConfigs: VpnConfig[]; vpnOverrides: Record; handleVpnSelection: ( profileId: string, vpnId: string | null, ) => void | Promise; // Extension groups (for Ext column lookup) extensionGroups: ExtensionGroup[]; // Click handlers for inline Ext / DNS cell editing onAssignExtensionGroup?: (profileIds: string[]) => void; setDnsBlocklistProfile: React.Dispatch< React.SetStateAction >; // Selection helpers isProfileSelected: (id: string) => boolean; handleToggleAll: (checked: boolean) => void; handleCheckboxChange: (id: string, checked: boolean) => void; handleIconClick: (id: string) => void; // Rename helpers handleRename: () => void | Promise; setProfileToRename: React.Dispatch< React.SetStateAction >; setNewProfileName: React.Dispatch>; setRenameError: React.Dispatch>; profileToRename: BrowserProfile | null; newProfileName: string; isRenamingSaving: boolean; renameError: string | null; // Launch/stop helpers setLaunchingProfiles: React.Dispatch>>; setStoppingProfiles: React.Dispatch>>; onKillProfile: (profile: BrowserProfile) => void | Promise; onLaunchProfile: (profile: BrowserProfile) => void | Promise; // Overflow actions onAssignProfilesToGroup?: (profileIds: string[]) => void; onConfigureCamoufox?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; onOpenCookieManagement?: (profile: BrowserProfile) => void; // Traffic snapshots (lightweight real-time data) trafficSnapshots: Record; onOpenTrafficDialog?: (profileId: string) => void; // Sync syncStatuses: Record; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void; crossOsUnlocked?: boolean; syncUnlocked?: boolean; // Country proxy creation (inline in proxy dropdown) countries: LocationItem[]; canCreateLocationProxy: boolean; loadCountries: () => Promise; handleCreateCountryProxy: ( profileId: string, country: LocationItem, ) => Promise; // Team locks isProfileLockedByAnother: (profileId: string) => boolean; getProfileLockEmail: (profileId: string) => string | undefined; // Synchronizer getProfileSyncInfo: (profileId: string) => | { session: SyncSessionInfo; isLeader: boolean; failedAtUrl: string | null; } | undefined; onLaunchWithSync: (profile: BrowserProfile) => void; } interface SyncStatusDot { color: string; tooltip: string; animate: boolean; encrypted: boolean; } function getProfileSyncStatusDot( profile: BrowserProfile, liveStatus: | "syncing" | "waiting" | "synced" | "error" | "disabled" | undefined, t: (key: string, options?: Record) => string, errorMessage?: string, ): SyncStatusDot | null { const encrypted = profile.sync_mode === "Encrypted"; const status = liveStatus ?? (profile.sync_mode && profile.sync_mode !== "Disabled" ? "synced" : "disabled"); switch (status) { case "syncing": return { color: "bg-warning", tooltip: t("profileTable.syncTooltipSyncing"), animate: true, encrypted, }; case "waiting": return { color: "bg-warning", tooltip: t("profileTable.syncTooltipCloseToSync"), animate: false, encrypted, }; case "synced": return { color: "bg-success", tooltip: profile.last_sync ? t("profileTable.syncTooltipSyncedAt", { time: new Date(profile.last_sync * 1000).toLocaleString(), }) : t("profileTable.syncTooltipSynced"), animate: false, encrypted, }; case "error": return { color: "bg-destructive", tooltip: errorMessage ? t("profileTable.syncTooltipErrorWith", { error: errorMessage }) : t("profileTable.syncTooltipError"), animate: false, encrypted, }; case "disabled": if (profile.last_sync) { return { color: "bg-muted-foreground", tooltip: t("profileTable.syncTooltipDisabledWithLast", { time: formatRelativeTime(profile.last_sync), }), animate: false, encrypted: false, }; } return null; default: return null; } } // Inline extension-group dropdown for the Ext column. Matches the // proxy column's Popover-style picker — no nested dialog. function ExtCell({ profile, meta, }: { profile: BrowserProfile; meta: TableMeta; }) { const [open, setOpen] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const groupId = profile.extension_group_id ?? null; const group = groupId ? meta.extensionGroups.find((g) => g.id === groupId) : undefined; const label = group?.name ?? meta.t("profiles.table.extDefault"); const onPick = async (nextId: string | null) => { setIsSaving(true); try { await invoke("assign_extension_group_to_profile", { profileId: profile.id, extensionGroupId: nextId, }); } catch (err) { console.error("Failed to assign extension group:", err); } finally { setIsSaving(false); setOpen(false); } }; return ( {meta.t("profiles.table.extEmpty")} { void onPick(null); }} > {groupId === null && } {meta.t("profiles.table.extDefault")} {meta.extensionGroups.map((g) => ( { void onPick(g.id); }} > {groupId === g.id && } {g.name} ))} ); } // Inline DNS blocklist dropdown — same Popover/Command pattern as Ext. function DnsCell({ profile, meta, }: { profile: BrowserProfile; meta: TableMeta; }) { const [open, setOpen] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const level = profile.dns_blocklist ?? null; // Backend levels are: light, normal, pro, pro_plus, ultimate (+ null). // Keep the list ordered from least to most restrictive. const LEVELS: { value: string; labelKey: string }[] = [ { value: "light", labelKey: "dnsBlocklist.light" }, { value: "normal", labelKey: "dnsBlocklist.normal" }, { value: "pro", labelKey: "dnsBlocklist.pro" }, { value: "pro_plus", labelKey: "dnsBlocklist.proPlus" }, { value: "ultimate", labelKey: "dnsBlocklist.ultimate" }, ]; const currentLabel = level === null ? null : (LEVELS.find((l) => l.value === level)?.labelKey ?? null); const onPick = async (nextLevel: string | null) => { setIsSaving(true); try { await invoke("update_profile_dns_blocklist", { profileId: profile.id, dnsBlocklist: nextLevel, }); } catch (err) { console.error("Failed to update DNS blocklist:", err); } finally { setIsSaving(false); setOpen(false); } }; return ( { void onPick(null); }} > {level === null && } {meta.t("dnsBlocklist.none")} {LEVELS.map((l) => ( { void onPick(l.value); }} > {level === l.value && } {meta.t(l.labelKey)} ))} ); } const TagsCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; tagsOverrides: Record; allTags: string[]; setAllTags: React.Dispatch>; openTagsEditorFor: string | null; setOpenTagsEditorFor: React.Dispatch>; setTagsOverrides: React.Dispatch< React.SetStateAction> >; }>( ({ profile, isDisabled, tagsOverrides, allTags, setAllTags, openTagsEditorFor, setOpenTagsEditorFor, setTagsOverrides, }) => { const { t: translate } = useTranslation(); const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id) ? tagsOverrides[profile.id] : (profile.tags ?? []); const valueOptions: Option[] = React.useMemo( () => effectiveTags.map((t) => ({ value: t, label: t })), [effectiveTags], ); const allOptions: Option[] = React.useMemo( () => allTags.map((t) => ({ value: t, label: t })), [allTags], ); const onTagsChange = React.useCallback( async (newTagsRaw: string[]) => { // Dedupe tags const seen = new Set(); const newTags: string[] = []; for (const t of newTagsRaw) { if (!seen.has(t)) { seen.add(t); newTags.push(t); } } setTagsOverrides((prev) => ({ ...prev, [profile.id]: newTags })); try { await invoke("update_profile_tags", { profileId: profile.id, tags: newTags, }); setAllTags((prev) => { const next = new Set(prev); for (const t of newTags) next.add(t); return Array.from(next).sort(); }); } catch (error) { console.error("Failed to update tags:", error); } }, [profile.id, setTagsOverrides, setAllTags], ); const handleChange = React.useCallback( async (opts: Option[]) => { const newTagsRaw = opts.map((o) => o.value); await onTagsChange(newTagsRaw); }, [onTagsChange], ); const containerRef = React.useRef(null); const editorRef = React.useRef(null); const [visibleCount, setVisibleCount] = React.useState( effectiveTags.length, ); const [isFocused, setIsFocused] = React.useState(false); React.useLayoutEffect(() => { // Only measure when not editing this profile's tags if (openTagsEditorFor === profile.id) return; const container = containerRef.current; if (!container) return; let timeoutId: number | undefined; const compute = () => { if (timeoutId) clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { const available = container.clientWidth; if (available <= 0) return; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return; const style = window.getComputedStyle(container); const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; ctx.font = font; const padding = 16; const gap = 4; let used = 0; let count = 0; for (let i = 0; i < effectiveTags.length; i++) { const text = effectiveTags[i]; const width = Math.ceil(ctx.measureText(text).width) + padding; const remaining = effectiveTags.length - (i + 1); let extra = 0; if (remaining > 0) { const plusText = `+${remaining}`; extra = Math.ceil(ctx.measureText(plusText).width) + padding; } const nextUsed = used + (used > 0 ? gap : 0) + width + (remaining > 0 ? gap + extra : 0); if (nextUsed <= available) { used += (used > 0 ? gap : 0) + width; count = i + 1; } else { break; } } setVisibleCount(count); }, 16); // Debounce with RAF timing }; compute(); const ro = new ResizeObserver(compute); ro.observe(container); return () => { ro.disconnect(); if (timeoutId) clearTimeout(timeoutId); }; }, [effectiveTags, openTagsEditorFor, profile.id]); React.useEffect(() => { if (openTagsEditorFor !== profile.id) return; const handleClick = (e: MouseEvent) => { const target = e.target as Node | null; if ( editorRef.current && target && !editorRef.current.contains(target) ) { setOpenTagsEditorFor(null); } }; document.addEventListener("mousedown", handleClick); return () => { document.removeEventListener("mousedown", handleClick); }; }, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]); React.useEffect(() => { if (openTagsEditorFor === profile.id && editorRef.current) { // Focus the inner input of MultipleSelector on open const inputEl = editorRef.current.querySelector("input"); if (inputEl) { inputEl.focus(); } } }, [openTagsEditorFor, profile.id]); if (openTagsEditorFor !== profile.id) { const hiddenCount = Math.max(0, effectiveTags.length - visibleCount); const ButtonContent = ( ); return (
{ButtonContent} {hiddenCount > 0 && (
{effectiveTags.map((t) => ( {t} ))}
)}
); } return (
void handleChange(opts)} creatable selectFirstItem={false} placeholder={ effectiveTags.length === 0 ? translate("profileTable.addTagsPlaceholder") : "" } className={cn( "bg-transparent border-0! focus-within:ring-0!", "[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!", "[&_div:first-child]:min-h-6! [&_div:first-child]:px-2! [&_div:first-child]:py-1!", "[&_div:first-child>div]:items-center [&_div:first-child>div]:h-6!", "[&_input]:ml-0! [&_input]:mt-0! [&_input]:px-0!", !isFocused && "[&_div:first-child>div]:justify-center", )} badgeClassName="shrink-0" inputProps={{ className: "!py-0 text-sm caret-current !ml-0 !mt-0 !px-0", onKeyDown: (e) => { if (e.key === "Escape") setOpenTagsEditorFor(null); }, onFocus: () => { setIsFocused(true); }, onBlur: () => { setIsFocused(false); }, }} />
); }, ); TagsCell.displayName = "TagsCell"; const NonHoverableTooltip = React.memo<{ children: React.ReactNode; content: React.ReactNode; sideOffset?: number; alignOffset?: number; horizontalOffset?: number; }>( ({ children, content, sideOffset = 4, alignOffset = 0, horizontalOffset = 0, }) => { const [isOpen, setIsOpen] = React.useState(false); return ( { setIsOpen(true); }} onMouseLeave={() => { setIsOpen(false); }} > {children} { e.preventDefault(); }} onPointerLeave={() => { setIsOpen(false); }} className="pointer-events-none" style={ horizontalOffset !== 0 ? { transform: `translateX(${horizontalOffset}px)` } : undefined } > {content} ); }, ); NonHoverableTooltip.displayName = "NonHoverableTooltip"; const NoteCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; noteOverrides: Record; openNoteEditorFor: string | null; setOpenNoteEditorFor: React.Dispatch>; setNoteOverrides: React.Dispatch< React.SetStateAction> >; }>( ({ profile, isDisabled, noteOverrides, openNoteEditorFor, setOpenNoteEditorFor, setNoteOverrides, }) => { const { t } = useTranslation(); const effectiveNote: string | null = Object.hasOwn( noteOverrides, profile.id, ) ? noteOverrides[profile.id] : (profile.note ?? null); const onNoteChange = React.useCallback( async (newNote: string | null) => { const trimmedNote = newNote?.trim() ?? null; setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote })); try { await invoke("update_profile_note", { profileId: profile.id, note: trimmedNote, }); } catch (error) { console.error("Failed to update note:", error); } }, [profile.id, setNoteOverrides], ); const editorRef = React.useRef(null); const textareaRef = React.useRef(null); const [noteValue, setNoteValue] = React.useState(effectiveNote ?? ""); // Update local state when effective note changes (from outside) React.useEffect(() => { if (openNoteEditorFor !== profile.id) { setNoteValue(effectiveNote ?? ""); } }, [effectiveNote, openNoteEditorFor, profile.id]); // Auto-resize textarea on open React.useEffect(() => { if (openNoteEditorFor === profile.id && textareaRef.current) { const textarea = textareaRef.current; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, [openNoteEditorFor, profile.id]); const handleTextareaChange = React.useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; setNoteValue(newValue); // Auto-resize const textarea = e.target; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; }, [], ); React.useEffect(() => { if (openNoteEditorFor !== profile.id) return; const handleClick = (e: MouseEvent) => { const target = e.target as Node | null; if ( editorRef.current && target && !editorRef.current.contains(target) ) { const currentValue = textareaRef.current?.value ?? ""; void onNoteChange(currentValue); setOpenNoteEditorFor(null); } }; document.addEventListener("mousedown", handleClick); return () => { document.removeEventListener("mousedown", handleClick); }; }, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]); React.useEffect(() => { if (openNoteEditorFor === profile.id && textareaRef.current) { textareaRef.current.focus(); // Move cursor to end const len = textareaRef.current.value.length; textareaRef.current.setSelectionRange(len, len); } }, [openNoteEditorFor, profile.id]); const displayNote = effectiveNote ?? ""; const trimmedNote = displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote; const showTooltip = displayNote.length > 12 || displayNote.length > 0; if (openNoteEditorFor !== profile.id) { return (
{showTooltip && (

{effectiveNote ?? t("profiles.note.empty")}

)}
); } return (