"use client"; import { type ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, type RowSelectionState, type SortingState, useReactTable, } from "@tanstack/react-table"; 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, LuPuzzle, LuTrash2, LuTriangleAlert, LuUsers, } from "react-icons/lu"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { ProfileBypassRulesDialog, ProfileInfoDialog, } 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 { ScrollArea } from "@/components/ui/scroll-area"; 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 { useTableSorting } from "@/hooks/use-table-sorting"; import { useTeamLocks } from "@/hooks/use-team-locks"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserDisplayName, getCurrentOS, 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, 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. type 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; // 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; }; type SyncStatusDot = { color: string; tooltip: string; animate: boolean; encrypted: boolean; }; function getProfileSyncStatusDot( profile: BrowserProfile, liveStatus: | "syncing" | "waiting" | "synced" | "error" | "disabled" | undefined, 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: "Syncing...", animate: true, encrypted, }; case "waiting": return { color: "bg-warning", tooltip: "Close the profile to sync", animate: false, encrypted, }; case "synced": return { color: "bg-success", tooltip: profile.last_sync ? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}` : "Synced", animate: false, encrypted, }; case "error": return { color: "bg-destructive", tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", animate: false, encrypted, }; case "disabled": if (profile.last_sync) { return { color: "bg-muted-foreground", tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`, animate: false, encrypted: false, }; } return null; default: return null; } } 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 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 as HTMLInputElement).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 ? "Add tags" : ""} 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 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 || "No Note"}

)}
); } return (