Files
donutbrowser/src/components/profile-data-table.tsx
T
2026-10-18 03:53:27 +04:00

4418 lines
151 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
flexRender,
type RowData,
type RowSelectionState,
type TableFeatures,
type ColumnVisibilityState as VisibilityState,
} from "@tanstack/react-table";
import {
type LegacyColumnDef as ColumnDef,
getCoreRowModel,
getSortedRowModel,
useLegacyTable as useReactTable,
} from "@tanstack/react-table/legacy";
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 { FiWifi } from "react-icons/fi";
import {
LuArrowDown,
LuArrowUp,
LuArrowUpDown,
LuCheck,
LuChevronDown,
LuCookie,
LuInfo,
LuLock,
LuMoon,
LuPlay,
LuPuzzle,
LuShuffle,
LuSlidersHorizontal,
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUserSearch,
LuUsers,
} from "react-icons/lu";
import { BulkEditDialog } from "@/components/bulk-edit-dialog";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
import {
describeCadence,
enableProfileSync,
minutesToClock,
outcomeLabel,
type PreflightFix,
preflight,
preflightFixLabel,
preflightReason,
runStatusLabel,
StatusDot,
sessionPhaseLabel,
sessionTone,
} from "@/components/cookie-bot-shared";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import {
ProfileGroupDragHandle,
useProfileGroupDrag,
} from "@/components/profile-group-drag";
import { ProfileHandoffStatus } from "@/components/profile-handoff-status";
import {
ProfileBypassRulesDialog,
ProfileDnsBlocklistDialog,
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import {
ProfileStatusChip,
ProfileStatusesDialog,
} from "@/components/profile-statuses-dialog";
import { SaveTemplateDialog } from "@/components/save-template-dialog";
import {
DATA_TABLE_CLASSES,
TableColumnResizer,
TableRowCheckbox,
TableSelectHeader,
TableViewControls,
} from "@/components/table-controls";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ProBadge } from "@/components/ui/pro-badge";
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 { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useLaunchActivity } from "@/hooks/use-launch-activity";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useRemoteHandoff } from "@/hooks/use-remote-handoff";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTablePreferences } from "@/hooks/use-table-preferences";
import { useTableKeyboard } from "@/hooks/use-table-tools";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getOSIcon,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { type CoachAnchorId, tipAnchor } from "@/lib/coach-mark";
import {
type CookieBotSchedule,
cancelCookieBotRun,
deleteCookieBotSchedule,
runCookieBotNow,
} from "@/lib/cookie-bot";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { canUseCookieBot } from "@/lib/entitlements";
import { formatRelativeTime } from "@/lib/flag-utils";
import type { RemoteHandoffState } from "@/lib/remote-sessions";
import { resolveTableVisibility } from "@/lib/table-tools";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProfileBotState,
ProfileStatus,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
VpnConfig,
} from "@/types";
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
import { Logo } from "./icons/logo";
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";
import { Skeleton } from "./ui/skeleton";
declare module "@tanstack/table-core" {
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue,
> {
// Emit no width for this column so table-fixed hands it all remaining
// space. Checking columnDef.size alone can't express this: TanStack
// resolves an unspecified size to its 150px default.
flexWidth?: boolean;
}
}
// Stable table meta type to pass volatile state/handlers into TanStack Table without
// causing column definitions to be recreated on every render.
/**
* The launch control's tooltip. While a launch is in flight it opens by
* itself and names the stage the backend is in, so a multi-second launch
* reads as progress rather than a bare spinner. It is click-through for
* that time, so it can never sit between the pointer and a neighbouring row.
*/
function LaunchControlTooltip({
profileId,
launching,
content,
children,
}: {
profileId: string;
launching: boolean;
content: React.ReactNode;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const stage = useLaunchActivity(profileId).at(-1)?.stage;
const live =
launching &&
stage !== undefined &&
stage !== "running" &&
stage !== "failed";
return (
<Tooltip open={live ? true : undefined}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{(live || content) && (
<TooltipContent
data-launch-stage={live ? stage : undefined}
className={cn(live && "pointer-events-none")}
>
{live ? t(`appFeedback.launch.${stage}`) : content}
</TooltipContent>
)}
</Tooltip>
);
}
interface TableMeta {
t: (key: string, options?: Record<string, unknown>) => string;
selectedProfiles: string[];
selectedDragProfiles: BrowserProfile[];
canDragProfiles: (profiles: BrowserProfile[]) => boolean;
isClient: boolean;
runningProfiles: Set<string>;
launchingProfiles: Set<string>;
stoppingProfiles: Set<string>;
isUpdating: (browser: string) => boolean;
browserState: ReturnType<typeof useBrowserState>;
// Tags editor state
tagsOverrides: Record<string, string[]>;
allTags: string[];
openTagsEditorFor: string | null;
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setTagsOverrides: React.Dispatch<
React.SetStateAction<Record<string, string[]>>
>;
// Note editor state
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
// Proxy selector state
openProxySelectorFor: string | null;
setOpenProxySelectorFor: React.Dispatch<React.SetStateAction<string | null>>;
proxyOverrides: Record<string, string | null>;
storedProxies: StoredProxy[];
handleProxySelection: (
profileId: string,
proxyId: string | null,
) => void | Promise<void>;
// VPN selector state
vpnConfigs: VpnConfig[];
vpnOverrides: Record<string, string | null>;
handleVpnSelection: (
profileId: string,
vpnId: string | null,
) => void | Promise<void>;
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
profileStatuses: ProfileStatus[];
onManageStatuses: () => void;
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
React.SetStateAction<BrowserProfile | null>
>;
// Selection helpers
// Rename helpers
handleRename: () => void | Promise<void>;
setProfileToRename: React.Dispatch<
React.SetStateAction<BrowserProfile | null>
>;
setNewProfileName: React.Dispatch<React.SetStateAction<string>>;
setRenameError: React.Dispatch<React.SetStateAction<string | null>>;
profileToRename: BrowserProfile | null;
newProfileName: string;
isRenamingSaving: boolean;
renameError: string | null;
// Launch/stop helpers
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureWayfern?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
// Traffic snapshots (lightweight real-time data)
trafficSnapshots: Record<string, TrafficSnapshot>;
onOpenTrafficDialog?: (profileId: string) => void;
// Sync
syncStatuses: Record<string, { status: string; error?: string }>;
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<void>;
handleCreateCountryProxy: (
profileId: string,
country: LocationItem,
) => Promise<void>;
// Team locks
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
// Remote execution.
//
// `getRemoteHandoff` is the authoritative answer to "can this be opened
// here", read from the same store the backend gate reads. The team-lock cache
// above cannot serve it: it refreshes on a 30-second poll and says nothing at
// all about a session that has finished but whose work has not been pulled
// back yet.
getRemoteHandoff: (profileId: string) => RemoteHandoffState | null;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
// Cookie Bot
cookieBotUnlocked: boolean;
/** Narrow container: the bot column shows its state mark without the label. */
cookieBotCompact: boolean;
getProfileBotState: (profileId: string) => ProfileBotState;
/** A run this desktop has just asked for, before the stream confirms it. */
botPendingProfiles: Set<string>;
onBotEnrol: (profile: BrowserProfile) => void;
onBotEdit: (profile: BrowserProfile, schedule: CookieBotSchedule) => void;
onBotRunNow: (profile: BrowserProfile) => void;
onBotStopRun: (runId: string) => void;
onBotViewActivity: (profile: BrowserProfile) => void;
onBotUnenrol: (profile: BrowserProfile) => void;
/**
* Perform the repair a failed preflight names, or null when this surface has
* no way to reach it. A reason with no affordance is what the menu showed
* before: "No proxy or VPN · Attach a proxy" as inert label text that reads
* like a button and answers no click.
*/
onBotFix: ((profile: BrowserProfile, fix: PreflightFix) => void) | null;
}
/**
* Below this container width the bot column keeps its state mark but drops the
* "next run" label: an operator still sees at a glance which rows are enrolled
* and which are running, and the row menu stays reachable, without taking the
* width the name needs.
*/
const BOT_LABEL_WIDTH = 880;
/** Below this the bot column leaves entirely, like the other low-priority ones. */
const BOT_COLUMN_MIN_WIDTH = 400;
/**
* Above this the table has room for the profile id. Below it the name column,
* which takes whatever the fixed columns leave over, needs those 100px more
* than a value that is already one click away in the info dialog.
*/
const PROFILE_ID_MIN_WIDTH = 1152;
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
/**
* Run statuses that mean the browser never came up.
*
* `POST /cookie-bot/runs` answers 202 with the run row it recorded, so a
* refusal — no capacity, no sites, a profile someone else has open — arrives as
* a successful response carrying a terminal status.
*/
const RUN_DID_NOT_START = new Set(["skipped", "failed", "cancelled"]);
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, unknown>) => 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;
}
}
/**
* An unset value in a row. A faint dash keeps a long table calm; the
* label stays for screen readers, and an editable cell names its action
* while the pointer is over it.
*/
function EmptyCellValue({
label,
hint,
}: {
label: string;
hint?: string | null;
}) {
return (
<span className="flex min-w-0 flex-1 truncate text-sm text-muted-foreground">
<span
aria-hidden="true"
className={cn("opacity-50", hint && "group-hover/cell:hidden")}
>
—
</span>
{hint && (
<span
aria-hidden="true"
className="hidden truncate group-hover/cell:inline"
>
{hint}
</span>
)}
<span className="sr-only">{label}</span>
</span>
);
}
// 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.none");
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex h-6 w-full items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
{group ? (
<span className="flex-1 truncate" title={label}>
{group.name}
</span>
) : (
<EmptyCellValue label={label} />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder={meta.t("profiles.table.extSearch")} />
<CommandList>
<CommandEmpty>{meta.t("profiles.table.extEmpty")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="__default__"
onSelect={() => {
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.none")}
</span>
</CommandItem>
{meta.extensionGroups.map((g) => (
<CommandItem
key={g.id}
value={g.name}
onSelect={() => {
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** The column headers a feature tip can point at. */
const COLUMN_ANCHORS: Partial<Record<string, CoachAnchorId>> = {
state: "column-state",
proxy: "column-proxy",
ext: "column-ext",
dns: "column-dns",
};
function columnAnchor(columnId: string) {
const anchor = COLUMN_ANCHORS[columnId];
return anchor ? tipAnchor(anchor) : {};
}
// 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;
const LEVELS = DNS_BLOCKLIST_LEVELS;
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
{...tipAnchor("row-dns")}
disabled={isSaving}
className="flex h-6 w-full items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
: meta.t("dnsBlocklist.none")
}
>
{currentLabel ? (
<span className="flex-1 truncate">{meta.t(currentLabel)}</span>
) : (
<EmptyCellValue label={meta.t("dnsBlocklist.none")} />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 size-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
</CommandItem>
{LEVELS.map((l) => (
<CommandItem
key={l.value}
value={l.value}
onSelect={() => {
void onPick(l.value);
}}
>
{level === l.value && <LuCheck className="mr-2 size-3.5" />}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Inline status dropdown, same Popover/Command pattern as DNS. A status is a
// label, so it stays editable while the profile runs.
function StatusCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const current =
meta.profileStatuses.find((status) => status.id === profile.status_id) ??
null;
const onPick = async (statusId: string | null) => {
setIsSaving(true);
try {
await invoke("set_profiles_status", {
profileIds: [profile.id],
statusId,
});
} catch (error) {
showErrorToast(translateBackendError(meta.t as never, error));
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="profile-status-cell"
disabled={isSaving}
className="flex h-6 w-full min-w-0 items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
{current ? (
<ProfileStatusChip status={current} />
) : (
<EmptyCellValue label={meta.t("profileStatuses.none")} />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{meta.profileStatuses.length > 8 && (
<CommandInput placeholder={meta.t("profileStatuses.search")} />
)}
<CommandList>
<CommandEmpty>{meta.t("profileStatuses.noMatch")}</CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => void onPick(null)}>
{current === null && <LuCheck className="mr-2 size-3.5" />}
<span className={current === null ? "" : "ml-5"}>
{meta.t("profileStatuses.none")}
</span>
</CommandItem>
{meta.profileStatuses.map((status) => (
<CommandItem
key={status.id}
value={`${status.name} ${status.id}`}
onSelect={() => void onPick(status.id)}
>
{current?.id === status.id && (
<LuCheck className="mr-2 size-3.5" />
)}
<span
className={
current?.id === status.id ? "min-w-0" : "ml-5 min-w-0"
}
>
<ProfileStatusChip status={status} />
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
value="__manage__"
onSelect={() => {
setOpen(false);
meta.onManageStatuses();
}}
>
<span className="ml-5">{meta.t("profileStatuses.manage")}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/**
* The first eight characters of the profile's UUID: the whole first group of a
* v4, which is also the prefix `id:` searches on, so what the row shows can be
* pasted straight back into the search box. The clipboard gets the FULL id —
* the only thing it is for is the REST and MCP APIs, which take nothing less.
*/
function ProfileIdCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
return (
<div className="flex h-7 w-full items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 truncate font-mono text-[11px] text-muted-foreground select-text">
{profile.id.slice(0, 8)}
</span>
</TooltipTrigger>
<TooltipContent className="font-mono text-[11px]">
{profile.id}
</TooltipContent>
</Tooltip>
<CopyToClipboard
text={profile.id}
variant="subtle"
className="row-reveal size-6 rounded-md"
successMessage={meta.t("toasts.success.copied")}
/>
</div>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
tagsOverrides: Record<string, string[]>;
allTags: string[];
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
openTagsEditorFor: string | null;
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setTagsOverrides: React.Dispatch<
React.SetStateAction<Record<string, string[]>>
>;
}>(
({
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<string>();
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<BrowserProfile>("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<HTMLDivElement | null>(null);
const editorRef = React.useRef<HTMLDivElement | null>(null);
const [visibleCount, setVisibleCount] = React.useState<number>(
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 = (
<button
type="button"
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
className={cn(
"group/cell flex h-6 w-full cursor-pointer items-center gap-1 overflow-hidden rounded-md border-none bg-transparent px-2 py-1 transition-colors duration-100",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-foreground/6",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.id);
}}
>
{effectiveTags.slice(0, visibleCount).map((t) => (
<Badge key={t} variant="soft" className="px-2 py-0 text-xs">
{t}
</Badge>
))}
{effectiveTags.length === 0 && (
<EmptyCellValue
hint={
isDisabled ? null : translate("profileTable.addTagsPlaceholder")
}
label={translate("profileTable.noTags")}
/>
)}
{hiddenCount > 0 && (
<Badge
variant="soft"
className="px-1.5 py-0 text-xs text-muted-foreground"
>
+{hiddenCount}
</Badge>
)}
</button>
);
return (
<div className="h-6 w-full cursor-pointer">
<Tooltip>
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
{hiddenCount > 0 && (
<TooltipContent className="max-w-[320px]">
<div className="flex flex-wrap gap-1">
{effectiveTags.map((t) => (
<Badge key={t} variant="soft" className="px-2 py-0 text-xs">
{t}
</Badge>
))}
</div>
</TooltipContent>
)}
</Tooltip>
</div>
);
}
return (
<div
className={cn(
"relative h-6 w-full",
isDisabled && "pointer-events-none opacity-60",
)}
>
<div
ref={editorRef}
className="absolute top-0 left-0 z-50 min-h-6 w-40 rounded-md bg-popover shadow-md"
>
<MultipleSelector
value={valueOptions}
options={allOptions}
onChange={(opts) => void handleChange(opts)}
creatable
selectFirstItem={false}
placeholder={
effectiveTags.length === 0
? translate("profileTable.addTagsPlaceholder")
: ""
}
className={cn(
"border-0! bg-transparent focus-within:ring-0! focus-within:ring-offset-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]:h-6! [&_div:first-child>div]:items-center",
"[&_input]:mt-0! [&_input]:ml-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);
},
}}
/>
</div>
</div>
);
},
);
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 (
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger
asChild
onMouseEnter={() => {
setIsOpen(true);
}}
onMouseLeave={() => {
setIsOpen(false);
}}
>
{children}
</TooltipTrigger>
<TooltipContent
sideOffset={sideOffset}
alignOffset={alignOffset}
arrowOffset={horizontalOffset}
onPointerEnter={(e) => {
e.preventDefault();
}}
onPointerLeave={() => {
setIsOpen(false);
}}
className="pointer-events-none"
style={
horizontalOffset !== 0
? { transform: `translateX(${horizontalOffset}px)` }
: undefined
}
>
{content}
</TooltipContent>
</Tooltip>
);
},
);
NonHoverableTooltip.displayName = "NonHoverableTooltip";
// CSS-truncated text whose tooltip only appears when the text actually
// overflows its column (measured on hover, so it tracks live resizes).
const OverflowTooltipText = React.memo<{
text: string;
className?: string;
}>(({ text, className }) => {
const textRef = React.useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = React.useState(false);
return (
<Tooltip
onOpenChange={(open) => {
if (!open) return;
const el = textRef.current;
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
}}
>
<TooltipTrigger asChild>
<span
ref={textRef}
className={cn("block max-w-full min-w-0 truncate", className)}
>
{text}
</span>
</TooltipTrigger>
{isOverflowing && <TooltipContent>{text}</TooltipContent>}
</Tooltip>
);
});
OverflowTooltipText.displayName = "OverflowTooltipText";
// Must be rendered inside a <Popover>; the tooltip shows the full assignment
// name only when it is truncated in the cell.
const ProxyCellTrigger = React.memo<{
displayName: string;
hasAssignment: boolean;
vpnBadge: string | null;
isDisabled: boolean;
}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => {
const textRef = React.useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = React.useState(false);
return (
<Tooltip
onOpenChange={(open) => {
if (!open) return;
const el = textRef.current;
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
}}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"flex h-6 max-w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1 transition-colors duration-100",
isDisabled
? "pointer-events-none cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-foreground/6",
)}
>
{vpnBadge && (
<Badge
variant="soft"
className="shrink-0 px-1 py-0 text-[10px] leading-tight"
>
{vpnBadge}
</Badge>
)}
{hasAssignment ? (
<span ref={textRef} className="min-w-0 truncate text-sm">
{displayName}
</span>
) : (
<EmptyCellValue label={displayName} />
)}
</span>
</PopoverTrigger>
</TooltipTrigger>
{hasAssignment && isOverflowing && (
<TooltipContent>{displayName}</TooltipContent>
)}
</Tooltip>
);
});
ProxyCellTrigger.displayName = "ProxyCellTrigger";
const NoteCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
}>(
({
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<BrowserProfile>("update_profile_note", {
profileId: profile.id,
note: trimmedNote,
});
} catch (error) {
console.error("Failed to update note:", error);
}
},
[profile.id, setNoteOverrides],
);
const editorRef = React.useRef<HTMLDivElement | null>(null);
const textareaRef = React.useRef<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
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 showTooltip = displayNote.length > 0;
if (openNoteEditorFor !== profile.id) {
return (
<div className="min-h-6 w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"group/cell flex min-h-6 w-full min-w-0 items-center rounded-md border-none bg-transparent px-2 py-1 text-left transition-colors duration-100",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-foreground/6",
)}
onClick={() => {
if (!isDisabled) {
setNoteValue(effectiveNote ?? "");
setOpenNoteEditorFor(profile.id);
}
}}
>
{effectiveNote ? (
<span className="block w-full truncate text-sm">
{displayNote}
</span>
) : (
<EmptyCellValue
hint={isDisabled ? null : t("profiles.note.placeholder")}
label={t("profiles.note.empty")}
/>
)}
</button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent className="max-w-[320px]">
<p className="wrap-break-word whitespace-pre-wrap">
{effectiveNote ?? t("profiles.note.empty")}
</p>
</TooltipContent>
)}
</Tooltip>
</div>
);
}
return (
<div
className={cn(
"relative w-full",
isDisabled && "pointer-events-none opacity-60",
)}
>
<div
ref={editorRef}
className="absolute top-[-15px] -left-px z-50 min-h-6 w-60 rounded-md border bg-popover shadow-md"
>
<textarea
ref={textareaRef}
value={noteValue}
onChange={handleTextareaChange}
onKeyDown={(e) => {
if (e.key === "Escape") {
setNoteValue(effectiveNote ?? "");
setOpenNoteEditorFor(null);
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}
}}
onBlur={() => {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}}
placeholder={t("profiles.note.placeholder")}
className="max-h-[200px] min-h-6 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm focus:ring-0 focus:outline-none"
style={{
overflow: "auto",
}}
rows={1}
/>
</div>
</div>
);
},
);
NoteCell.displayName = "NoteCell";
/** `HH:MM` of the server's own next-run instant, in this machine's locale. */
function formatNextRun(schedule: CookieBotSchedule): string | null {
if (!schedule.next_run_at) return null;
const date = new Date(schedule.next_run_at);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
/**
* One row's Cookie Bot state, and the row's bot actions.
*
* The whole cell is the menu trigger. A dedicated kebab would cost another
* column of width in a table that is already dense, and the state mark is
* exactly the thing an operator reaches for when they want to change it. The
* dot, the tone and the phase wording all come from the shared status
* vocabulary, so a run cannot read one way here and another on the Cookie Bot
* page.
*/
const BotCell = React.memo<{
profile: BrowserProfile;
meta: TableMeta;
}>(({ profile, meta }) => {
// Own `t` rather than `meta.t`: the shared status helpers take a real
// `TFunction`, and every other cell in this file resolves it the same way.
const { t } = useTranslation();
const { schedule, liveSession } = meta.getProfileBotState(profile.id);
const check = preflight(profile);
const isPending = meta.botPendingProfiles.has(profile.id);
const isLive = liveSession !== null;
const nextRun = schedule ? formatNextRun(schedule) : null;
// The server computes "why tonight would be refused" on every read, precisely
// so a detached proxy is visible in the afternoon rather than announcing
// itself as a skipped run at 02:00. Dropping it left a broken enrolment
// showing a healthy dot and a next-run time it could never keep.
const blockedReason =
schedule?.enabled && schedule.blocked_by
? outcomeLabel(t, schedule.blocked_by)
: null;
// `provisioning` is a transfer in progress — the one meaning the app already
// reserves a pulsing dot for. Nothing else pulses.
const isPreparing = liveSession?.state === "provisioning" || isPending;
const tone = liveSession
? sessionTone(liveSession)
: isPending
? "warning"
: schedule
? schedule.enabled && !blockedReason
? "muted"
: "warning"
: null;
const label = isPending
? t("cookieBot.status.provisioning")
: liveSession
? sessionPhaseLabel(t, liveSession)
: schedule
? !schedule.enabled
? t("cookieBot.state.paused")
: (blockedReason ?? nextRun ?? t("cookieBot.state.enrolled"))
: "—";
const summary = schedule
? blockedReason
? t("cookieBot.state.blocked", { reason: blockedReason })
: t("cookieBot.state.summary", {
cadence: describeCadence(t, schedule.days_mask),
time: minutesToClock(schedule.run_at_minute),
})
: check.eligible
? t("cookieBot.state.notEnrolled")
: // The repair is its own menu item when this surface can reach it, so
// the label stays a statement instead of looking like a second button.
[
preflightReason(t, check),
meta.onBotFix ? null : preflightFixLabel(t, check.fix),
]
.filter(Boolean)
.join(" · ");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t("cookieBot.state.rowMenu", { name: profile.name })}
className="flex h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md border-none bg-transparent px-2 text-left transition-colors duration-100 hover:bg-foreground/6"
>
{tone ? (
<StatusDot tone={tone} pulse={isPreparing} className="size-1.5" />
) : (
<span aria-hidden="true" className="size-1.5 shrink-0" />
)}
{!meta.cookieBotCompact && (
<span
className={cn(
"min-w-0 truncate text-xs tabular-nums",
isLive || isPending
? "text-foreground"
: "text-muted-foreground",
)}
>
{label}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel className="font-normal text-muted-foreground">
{summary}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{schedule ? (
<>
<DropdownMenuItem
onClick={() => {
meta.onBotEdit(profile, schedule);
}}
>
{t("cookieBot.actions.editSchedule")}
</DropdownMenuItem>
{liveSession?.run_id ? (
<DropdownMenuItem
onClick={() => {
if (liveSession.run_id) {
meta.onBotStopRun(liveSession.run_id);
}
}}
>
{t("cookieBot.running.stop")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={isLive || isPending}
onClick={() => {
meta.onBotRunNow(profile);
}}
>
{t("cookieBot.actions.runNow")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
meta.onBotViewActivity(profile);
}}
>
{t("cookieBot.actions.viewActivity")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
meta.onBotUnenrol(profile);
}}
>
{t("cookieBot.schedule.unenrol")}
</DropdownMenuItem>
</>
) : (
<>
{!check.eligible && check.fix && meta.onBotFix && (
<DropdownMenuItem
onClick={() => {
meta.onBotFix?.(profile, check.fix as PreflightFix);
}}
>
{preflightFixLabel(t, check.fix)}
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!check.eligible}
onClick={() => {
meta.onBotEnrol(profile);
}}
>
{t("cookieBot.actions.enrol")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
});
BotCell.displayName = "BotCell";
interface ProfilesDataTableProps {
filterControls?: React.ReactNode;
onClearFilters?: () => void;
profiles: BrowserProfile[];
allProfiles?: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
onConfigureWayfern: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
/**
* Loaded by the page rather than here, because the search filter resolves
* `ext:` to a group name and needs the same list. One invoke, one listener.
*/
extensionGroups: ExtensionGroup[];
/** Loaded by the page for the same reason: `state:` resolves to names. */
profileStatuses?: ProfileStatus[];
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
/** Opens proxy assignment for a specific set of profiles. */
onAssignProfilesToProxy?: (profileIds: string[]) => void;
selectedGroupId: string | null;
selectedProfiles: string[];
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
onBulkDelete?: () => void;
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
onBulkProxyDistribution?: () => void;
onBulkCopyCookies?: () => void;
onBulkRun?: () => void;
onBulkStop?: () => void;
bulkActionsUnlocked?: boolean;
onBulkExtensionGroupAssignment?: () => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
getProfileSyncInfo?: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
/**
* When provided, the info dialog is controlled by the parent. Allows the
* command palette in page.tsx to open the dialog directly without lifting
* every other piece of internal table state.
*/
infoDialogProfile?: BrowserProfile | null;
infoOpenMethod?: "pointer" | "keyboard";
onInfoOpenMethodChange?: (method: "pointer" | "keyboard") => void;
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
/** Initial data load in flight — renders skeleton rows instead of "empty". */
isLoading?: boolean;
/** True when the app has zero profiles overall (not just a filtered view). */
showOnboardingEmptyState?: boolean;
onCreateProfile?: () => void;
onImportProfiles?: () => void;
}
const EMPTY_STATUSES: ProfileStatus[] = [];
export function ProfilesDataTable({
filterControls,
onClearFilters,
profiles,
allProfiles = profiles,
onLaunchProfile,
onKillProfile,
onCloneProfile,
onDeleteProfile,
onRenameProfile,
onConfigureWayfern,
onCopyCookiesToProfile,
onOpenCookieManagement,
runningProfiles,
extensionGroups,
profileStatuses = EMPTY_STATUSES,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
selectedProfiles,
onSelectedProfilesChange,
onBulkDelete,
onBulkGroupAssignment,
onBulkProxyAssignment,
onBulkProxyDistribution,
onBulkCopyCookies,
onBulkRun,
onBulkStop,
bulkActionsUnlocked = false,
onBulkExtensionGroupAssignment,
onAssignExtensionGroup,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked = false,
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
onSetPassword,
onChangePassword,
onRemovePassword,
infoDialogProfile,
infoOpenMethod,
onInfoOpenMethodChange,
onInfoDialogProfileChange,
isLoading = false,
showOnboardingEmptyState = false,
onCreateProfile,
onImportProfiles,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const profileDrag = useProfileGroupDrag();
const [internalInfoOpenMethod, setInternalInfoOpenMethod] = React.useState<
"pointer" | "keyboard"
>("pointer");
const view = useTablePreferences("profiles");
const sorting = view.preferences.sorting;
const rowSelection = React.useMemo<RowSelectionState>(
() => Object.fromEntries(selectedProfiles.map((id) => [id, true])),
[selectedProfiles],
);
const handleRowSelectionChange = React.useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
onSelectedProfilesChange((previous) => {
const selection = Object.fromEntries(
previous.map((id) => [id, true as const]),
);
const next =
typeof updater === "function" ? updater(selection) : updater;
return Object.keys(next).filter((id) => next[id]);
});
},
[onSelectedProfilesChange],
);
const [profileToRename, setProfileToRename] =
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
const [renameError, setRenameError] = React.useState<string | null>(null);
const [isRenamingSaving, setIsRenamingSaving] = React.useState(false);
const renameContainerRef = React.useRef<HTMLDivElement | null>(null);
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
React.useState<BrowserProfile | null>(null);
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
const profileForInfoDialog = isInfoDialogControlled
? (infoDialogProfile ?? null)
: internalInfoDialogProfile;
const setProfileForInfoDialog = React.useCallback(
(p: BrowserProfile | null) => {
if (isInfoDialogControlled) {
onInfoDialogProfileChange?.(p);
} else {
setInternalInfoDialogProfile(p);
}
},
[isInfoDialogControlled, onInfoDialogProfileChange],
);
React.useEffect(() => {
if (
profileForInfoDialog &&
!allProfiles.some((profile) => profile.id === profileForInfoDialog.id)
) {
setProfileForInfoDialog(null);
}
}, [allProfiles, profileForInfoDialog, setProfileForInfoDialog]);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
const [bulkEditOpen, setBulkEditOpen] = React.useState(false);
const [templateProfile, setTemplateProfile] =
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(),
);
const [stoppingProfiles, setStoppingProfiles] = React.useState<Set<string>>(
new Set(),
);
const { storedProxies } = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
const { user } = useCloudAuth();
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
// Which profiles cannot be opened on this computer, and why. Event-driven and
// read from the backend's own gate, so the button state and the refusal the
// backend would give can never disagree.
const { handoffFor } = useRemoteHandoff();
// Cookie Bot. Enrolments and live runs both live server-side, so the table
// reads them from the shared store rather than from BrowserProfile.
const cookieBotUnlocked = canUseCookieBot(user);
const {
scheduleFor,
liveSessionFor,
refresh: refreshCookieBotState,
} = useCookieBot(cookieBotUnlocked, cookieBotScopeFor(user));
const [botPendingProfiles, setBotPendingProfiles] = React.useState<
Set<string>
>(new Set());
const [botScheduleDialog, setBotScheduleDialog] = React.useState<{
profiles: BrowserProfile[];
existing: CookieBotSchedule | null;
} | null>(null);
const [botRunsProfile, setBotRunsProfile] =
React.useState<BrowserProfile | null>(null);
const [botUnenrolProfile, setBotUnenrolProfile] =
React.useState<BrowserProfile | null>(null);
const [isUnenrolling, setIsUnenrolling] = React.useState(false);
const [pendingBulkEnrol, setPendingBulkEnrol] = React.useState<
BrowserProfile[] | null
>(null);
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
const [proxyOverrides, setProxyOverrides] = React.useState<
Record<string, string | null>
>({});
const [vpnOverrides, setVpnOverrides] = React.useState<
Record<string, string | null>
>({});
const [tagsOverrides, setTagsOverrides] = React.useState<
Record<string, string[]>
>({});
const [allTags, setAllTags] = React.useState<string[]>([]);
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
string | null
>(null);
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
string | null
>(null);
const [noteOverrides, setNoteOverrides] = React.useState<
Record<string, string | null>
>({});
const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState<
string | null
>(null);
const [trafficSnapshots, setTrafficSnapshots] = React.useState<
Record<string, TrafficSnapshot>
>({});
const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{
id: string;
name?: string;
} | null>(null);
const [syncStatuses, setSyncStatuses] = React.useState<
Record<string, { status: string; error?: string }>
>({});
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return;
try {
const data = await invoke<LocationItem[]>("cloud_get_countries");
setCountries(data);
setCountriesLoaded(true);
} catch (e) {
console.error("Failed to load countries:", e);
}
}, [countriesLoaded]);
const loadAllTags = React.useCallback(async () => {
try {
const tags = await invoke<string[]>("get_all_tags");
setAllTags(tags);
} catch (error) {
console.error("Failed to load tags:", error);
}
}, []);
const handleProxySelection = React.useCallback(
async (profileId: string, proxyId: string | null) => {
try {
await invoke("update_profile_proxy", {
profileId,
proxyId,
});
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
setVpnOverrides((prev) => ({ ...prev, [profileId]: null }));
await emit("profile-updated");
} catch (error) {
console.error("Failed to update proxy settings:", error);
} finally {
setOpenProxySelectorFor(null);
}
},
[],
);
const handleVpnSelection = React.useCallback(
async (profileId: string, vpnId: string | null) => {
try {
await invoke("update_profile_vpn", {
profileId,
vpnId,
});
setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId }));
setProxyOverrides((prev) => ({ ...prev, [profileId]: null }));
await emit("profile-updated");
} catch (error) {
console.error("Failed to update VPN settings:", error);
} finally {
setOpenProxySelectorFor(null);
}
},
[],
);
const handleCreateCountryProxy = React.useCallback(
async (profileId: string, country: LocationItem) => {
try {
await invoke("create_cloud_location_proxy", {
name: country.name,
country: country.code,
region: null,
city: null,
isp: null,
});
await emit("stored-proxies-changed");
// Wait briefly for proxy list to update, then find and assign the new proxy
await new Promise((r) => setTimeout(r, 200));
const updatedProxies =
await invoke<StoredProxy[]>("get_stored_proxies");
const newProxy = updatedProxies.find(
(p: StoredProxy) =>
p.is_cloud_derived && p.geo_country === country.code,
);
if (newProxy) {
await handleProxySelection(profileId, newProxy.id);
}
setOpenProxySelectorFor(null);
} catch (error) {
console.error("Failed to create country proxy:", error);
}
},
[handleProxySelection],
);
const getProfileBotState = React.useCallback(
(profileId: string): ProfileBotState => ({
schedule: scheduleFor(profileId),
liveSession: liveSessionFor(profileId),
}),
[scheduleFor, liveSessionFor],
);
const handleBotEnrol = React.useCallback((profile: BrowserProfile) => {
setBotScheduleDialog({ profiles: [profile], existing: null });
}, []);
const handleBotEdit = React.useCallback(
(profile: BrowserProfile, schedule: CookieBotSchedule) => {
setBotScheduleDialog({ profiles: [profile], existing: schedule });
},
[],
);
const handleBotViewActivity = React.useCallback((profile: BrowserProfile) => {
setBotRunsProfile(profile);
}, []);
const handleBotFix = React.useCallback(
(profile: BrowserProfile, fix: PreflightFix) => {
if (fix === "proxy") {
onAssignProfilesToProxy?.([profile.id]);
return;
}
if (fix === "syncSettings") {
onOpenProfileSyncDialog?.(profile);
return;
}
void enableProfileSync(profile.id).catch((error: unknown) => {
showErrorToast(
parseBackendError(error)
? translateBackendError(t as never, error)
: t("cookieBot.preflight.fixFailed"),
);
});
},
[onAssignProfilesToProxy, onOpenProfileSyncDialog, t],
);
// Null rather than a no-op when nothing is wired: the menu then states the
// reason without offering a repair it cannot perform.
const botFixHandler =
onAssignProfilesToProxy || onOpenProfileSyncDialog ? handleBotFix : null;
const handleBotRunNow = React.useCallback(
async (profile: BrowserProfile) => {
// Held locally until the stream reports the session: the run is real the
// moment the command returns, and a row that still looks idle invites a
// second click that would spend a second hour.
setBotPendingProfiles((prev) => new Set(prev).add(profile.id));
try {
const started = await runCookieBotNow(profile.id);
// 202, not 200: the route answers with a RECORDED run, and a run that
// could not get a host comes back already terminal, carrying an
// `outcome_code`, rather than as an HTTP error. Treating every 2xx as
// "started" told a user their run had begun on a night when no Windows
// host was free, and the only trace was a row in a history panel they
// had to go and open.
if (RUN_DID_NOT_START.has(started.run.status)) {
showErrorToast(
t("cookieBot.actions.runNotStarted", {
reason:
outcomeLabel(t as never, started.run.outcome_code) ??
runStatusLabel(t as never, started.run.status),
}),
);
} else {
showSuccessToast(t("cookieBot.actions.runStarted"));
}
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setBotPendingProfiles((prev) => {
const next = new Set(prev);
next.delete(profile.id);
return next;
});
}
},
[refreshCookieBotState, t],
);
const handleBotStopRun = React.useCallback(
async (runId: string) => {
try {
await cancelCookieBotRun(runId);
showSuccessToast(t("cookieBot.running.stopped"));
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
}
},
[refreshCookieBotState, t],
);
const handleBotUnenrol = React.useCallback(async () => {
if (!botUnenrolProfile) return;
setIsUnenrolling(true);
try {
await deleteCookieBotSchedule(botUnenrolProfile.id);
showSuccessToast(t("cookieBot.schedule.unenrolled"));
setBotUnenrolProfile(null);
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsUnenrolling(false);
}
}, [botUnenrolProfile, refreshCookieBotState, t]);
const handleBulkCookieBotEnrol = React.useCallback(() => {
const targets = profiles.filter((p) => selectedProfiles.includes(p.id));
if (targets.length === 0) return;
const eligible = targets.filter((p) => preflight(p).eligible);
// Same guard as bulk run: an action that can touch nothing says so instead
// of opening a dialog whose only outcome is a refusal.
if (eligible.length === 0) {
showErrorToast(t("cookieBot.actionBar.noneEligible"));
return;
}
// Ten or more is the threshold bulk run and stop already use, and enrolling
// is the heavier commitment of the three: each row books a nightly job
// against a shared budget.
if (eligible.length >= BULK_ENROL_CONFIRM_THRESHOLD) {
setPendingBulkEnrol(targets);
return;
}
setBotScheduleDialog({ profiles: targets, existing: null });
}, [profiles, selectedProfiles, t]);
// Use shared browser state hook
const browserState = useBrowserState(
profiles,
runningProfiles,
isUpdating,
launchingProfiles,
stoppingProfiles,
);
// Listen for sync status events
React.useEffect(() => {
if (!browserState.isClient) return;
let unlisten: (() => void) | undefined;
void (async () => {
try {
unlisten = await listen<{
profile_id: string;
status: string;
error?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
setSyncStatuses((prev) => ({
...prev,
[profile_id]: { status, error },
}));
});
} catch (error) {
console.error("Failed to listen for sync status events:", error);
}
})();
return () => {
if (unlisten) unlisten();
};
}, [browserState.isClient]);
// Fetch traffic snapshots for running profiles (lightweight, real-time data)
// Convert Set to sorted array to avoid Set reference comparison issues in dependencies
const runningProfileIds = React.useMemo(
() => Array.from(runningProfiles).sort(),
[runningProfiles],
);
const runningCount = runningProfileIds.length;
React.useEffect(() => {
if (!browserState.isClient) return;
if (runningCount === 0) {
setTrafficSnapshots({});
return;
}
const fetchTrafficSnapshots = async () => {
try {
const allSnapshots = await invoke<TrafficSnapshot[]>(
"get_all_traffic_snapshots",
);
const newSnapshots: Record<string, TrafficSnapshot> = {};
// O(1) membership; runningProfileIds.includes() in this loop would be
// O(snapshots * runningProfiles).
const runningSet = new Set(runningProfileIds);
for (const snapshot of allSnapshots) {
if (snapshot.profile_id) {
// Only keep snapshots for profiles that are currently running
if (runningSet.has(snapshot.profile_id)) {
const existing = newSnapshots[snapshot.profile_id];
if (!existing || snapshot.last_update > existing.last_update) {
newSnapshots[snapshot.profile_id] = snapshot;
}
}
}
}
setTrafficSnapshots(newSnapshots);
} catch (error) {
console.error("Failed to fetch traffic snapshots:", error);
}
};
void fetchTrafficSnapshots();
const interval = setInterval(() => {
void fetchTrafficSnapshots();
}, 1000);
return () => {
clearInterval(interval);
};
}, [browserState.isClient, runningCount, runningProfileIds]);
// Clean up snapshots for profiles that are no longer running
React.useEffect(() => {
if (!browserState.isClient) return;
setTrafficSnapshots((prev) => {
const cleaned: Record<string, TrafficSnapshot> = {};
const runningSet = new Set(runningProfileIds);
for (const [profileId, snapshot] of Object.entries(prev)) {
// Only keep snapshots for profiles that are currently running
if (runningSet.has(profileId)) {
cleaned[profileId] = snapshot;
}
}
// Only update if something was removed
if (Object.keys(cleaned).length !== Object.keys(prev).length) {
return cleaned;
}
return prev;
});
}, [browserState.isClient, runningProfileIds]);
// Clear launching/stopping spinners when backend reports running status changes
React.useEffect(() => {
if (!browserState.isClient) return;
let unlisten: (() => void) | undefined;
void (async () => {
try {
unlisten = await listen<{ id: string; is_running: boolean }>(
"profile-running-changed",
(event) => {
const { id } = event.payload;
// Clear launching state for this profile if present
setLaunchingProfiles((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// Clear stopping state for this profile if present
setStoppingProfiles((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
},
);
} catch (error) {
console.error("Failed to listen for profile running changes:", error);
}
})();
return () => {
if (unlisten) unlisten();
};
}, [browserState.isClient]);
// Keep stored proxies up-to-date by listening for changes emitted elsewhere in the app
React.useEffect(() => {
if (!browserState.isClient) return;
let unlisten: (() => void) | undefined;
void (async () => {
try {
unlisten = await listen("stored-proxies-changed", () => {
// Also refresh tags on profile updates
void loadAllTags();
});
} catch (_err) {
// Best-effort only
}
})();
return () => {
if (unlisten) unlisten();
};
}, [browserState.isClient, loadAllTags]);
// A running browser keeps the name it launched with, so close any inline
// rename that was open when the profile entered a runtime transition.
React.useEffect(() => {
if (!profileToRename) return;
const profileId = profileToRename.id;
const isRuntimeLocked =
(browserState.isClient && runningProfiles.has(profileId)) ||
launchingProfiles.has(profileId) ||
stoppingProfiles.has(profileId);
if (isRuntimeLocked) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}, [
profileToRename,
runningProfiles,
launchingProfiles,
stoppingProfiles,
browserState.isClient,
]);
React.useLayoutEffect(() => {
const visible = new Set(profiles.map((profile) => profile.id));
onSelectedProfilesChange((prev) =>
prev.every((id) => visible.has(id))
? prev
: prev.filter((id) => visible.has(id)),
);
}, [profiles, onSelectedProfilesChange]);
// Automatically deselect profiles that become running, updating, launching, or stopping
React.useEffect(() => {
const newSet = new Set(selectedProfiles);
let hasChanges = false;
for (const profileId of selectedProfiles) {
const profile = profiles.find((p) => p.id === profileId);
if (profile) {
const isRunning =
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
if (isRunning || isLaunching || isStopping) {
newSet.delete(profileId);
hasChanges = true;
}
}
}
if (hasChanges) {
onSelectedProfilesChange(Array.from(newSet));
}
}, [
profiles,
runningProfiles,
launchingProfiles,
stoppingProfiles,
browserState.isClient,
onSelectedProfilesChange,
selectedProfiles,
]);
const handleRename = React.useCallback(async () => {
if (!profileToRename || !newProfileName.trim()) return;
try {
setIsRenamingSaving(true);
await onRenameProfile(profileToRename.id, newProfileName.trim());
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
} catch (error) {
setRenameError(
error instanceof Error
? error.message
: t("errors.renameProfileFailed", { error: String(error) }),
);
} finally {
setIsRenamingSaving(false);
}
}, [profileToRename, newProfileName, onRenameProfile, t]);
// Cancel inline rename on outside click
React.useEffect(() => {
if (!profileToRename) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (
target &&
renameContainerRef.current &&
!renameContainerRef.current.contains(target)
) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileToRename]);
const handleDelete = async () => {
if (!profileToDelete) return;
setIsDeleting(true);
// Minimum loading time for visual feedback
const minLoadingTime = new Promise((r) => setTimeout(r, 300));
try {
await Promise.all([onDeleteProfile(profileToDelete), minLoadingTime]);
setProfileToDelete(null);
} catch (error) {
console.error("Failed to delete profile:", error);
} finally {
setIsDeleting(false);
}
};
React.useEffect(() => {
if (browserState.isClient) {
void loadAllTags();
}
}, [browserState.isClient, loadAllTags]);
// Build table meta from volatile state so columns can stay stable
const selectedDragProfiles = React.useMemo(() => {
const ids = new Set(selectedProfiles);
return allProfiles.filter((profile) => ids.has(profile.id));
}, [allProfiles, selectedProfiles]);
const profilesById = React.useMemo(
() => new Map(allProfiles.map((profile) => [profile.id, profile])),
[allProfiles],
);
const canDragProfiles = React.useCallback(
(targets: BrowserProfile[]) => {
return (
browserState.isClient &&
targets.length > 0 &&
targets.every((captured) => {
const profile = profilesById.get(captured.id);
return (
profile !== undefined &&
profile.process_id == null &&
!runningProfiles.has(profile.id) &&
!launchingProfiles.has(profile.id) &&
!stoppingProfiles.has(profile.id) &&
!isCrossOsProfile(profile) &&
!isProfileLocked(profile.id) &&
!handoffFor(profile.id)
);
})
);
},
[
browserState.isClient,
profilesById,
runningProfiles,
launchingProfiles,
stoppingProfiles,
isProfileLocked,
handoffFor,
],
);
const tableMeta = React.useMemo<TableMeta>(
() => ({
t,
selectedProfiles,
selectedDragProfiles,
canDragProfiles,
isClient: browserState.isClient,
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
browserState,
// Tags editor state
tagsOverrides,
allTags,
openTagsEditorFor,
setAllTags,
setOpenTagsEditorFor,
setTagsOverrides,
// Note editor state
noteOverrides,
openNoteEditorFor,
setOpenNoteEditorFor,
setNoteOverrides,
// Proxy selector state
openProxySelectorFor,
setOpenProxySelectorFor,
proxyOverrides,
storedProxies,
handleProxySelection,
// VPN selector state
vpnConfigs,
vpnOverrides,
handleVpnSelection,
// Extension groups
extensionGroups,
onAssignExtensionGroup,
setDnsBlocklistProfile,
profileStatuses,
onManageStatuses: () => setStatusesDialogOpen(true),
// Rename helpers
handleRename,
setProfileToRename,
setNewProfileName,
setRenameError,
profileToRename,
newProfileName,
isRenamingSaving,
renameError,
// Launch/stop helpers
setLaunchingProfiles,
setStoppingProfiles,
onKillProfile,
onLaunchProfile,
// Overflow actions
onAssignProfilesToGroup,
onCloneProfile: onCloneProfile
? (profile: BrowserProfile) => {
void onCloneProfile(profile);
}
: undefined,
onConfigureWayfern,
onCopyCookiesToProfile,
onOpenCookieManagement,
// Traffic snapshots (lightweight real-time data)
trafficSnapshots,
onOpenTrafficDialog: (profileId: string) => {
const profile = profiles.find((p) => p.id === profileId);
setTrafficDialogProfile({ id: profileId, name: profile?.name });
},
// Sync
syncStatuses,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked,
syncUnlocked,
// Country proxy creation
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
// Team locks
isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Remote execution
getRemoteHandoff: handoffFor,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync:
onLaunchWithSync ??
(() => {
/* empty */
}),
// Cookie Bot
cookieBotUnlocked,
cookieBotCompact: containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH,
getProfileBotState,
botPendingProfiles,
onBotEnrol: handleBotEnrol,
onBotEdit: handleBotEdit,
onBotRunNow: (profile: BrowserProfile) => {
void handleBotRunNow(profile);
},
onBotStopRun: (runId: string) => {
void handleBotStopRun(runId);
},
onBotViewActivity: handleBotViewActivity,
onBotUnenrol: setBotUnenrolProfile,
onBotFix: botFixHandler,
}),
[
t,
selectedProfiles,
selectedDragProfiles,
canDragProfiles,
browserState.isClient,
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
browserState,
tagsOverrides,
allTags,
openTagsEditorFor,
noteOverrides,
openNoteEditorFor,
openProxySelectorFor,
proxyOverrides,
storedProxies,
handleProxySelection,
vpnConfigs,
vpnOverrides,
handleVpnSelection,
extensionGroups,
onAssignExtensionGroup,
handleRename,
profileToRename,
newProfileName,
isRenamingSaving,
trafficSnapshots,
profiles,
renameError,
onKillProfile,
onLaunchProfile,
onAssignProfilesToGroup,
onCloneProfile,
onConfigureWayfern,
onCopyCookiesToProfile,
onOpenCookieManagement,
syncStatuses,
onOpenProfileSyncDialog,
onToggleProfileSync,
crossOsUnlocked,
syncUnlocked,
countries,
loadCountries,
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
handoffFor,
getProfileSyncInfo,
onLaunchWithSync,
cookieBotUnlocked,
containerWidth,
getProfileBotState,
botPendingProfiles,
handleBotEnrol,
handleBotEdit,
handleBotRunNow,
handleBotStopRun,
handleBotViewActivity,
botFixHandler,
profileStatuses,
],
);
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
() => [
{
id: "select",
header: ({ table }) => <TableSelectHeader table={table} />,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const crossOs = isCrossOsProfile(profile);
const os = profile.host_os || profile.wayfern_config?.os || "";
const Icon = crossOs ? getOSIcon(os) : getProfileIcon(profile);
const message = crossOs
? t("crossOs.viewOnly", { os: getOSDisplayName(os) })
: meta.runningProfiles.has(profile.id)
? t("profiles.table.cantModifyRunning")
: meta.launchingProfiles.has(profile.id)
? t("profiles.table.cantModifyLaunching")
: meta.stoppingProfiles.has(profile.id)
? t("profiles.table.cantModifyStopping")
: getBrowserDisplayName(profile.browser);
return (
<NonHoverableTooltip
content={<p>{message}</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="block">
<TableRowCheckbox
table={table}
row={row}
icon={
Icon ? (
<Icon
className={cn(
"size-4",
!row.getCanSelect() && "opacity-50",
)}
/>
) : undefined
}
/>
</span>
</NonHoverableTooltip>
);
},
enableSorting: false,
enableHiding: false,
enableResizing: false,
size: 28,
},
{
id: "actions",
size: 44,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const handoff = meta.getRemoteHandoff(profile.id);
// A profile open on the fleet IS running, and the button has to say
// so: it is the control that stops it, and stopping now reaches the
// remote browser rather than looking for a local process that was
// never there.
const isRunningRemotely = handoff === "running";
const isPendingRemotePull = handoff === "pending_sync";
const isRunning =
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
isRunningRemotely;
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing";
// A remote session holds the profile lock under a different holder
// than this device, so `isLockedByAnother` is true for the user's OWN
// remote session. That must not disable the control that stops it.
const canLaunch = isRunningRemotely
? true
: meta.browserState.canLaunchProfile(profile) &&
!isPendingRemotePull &&
!isLockedByAnother &&
!isSyncing;
const lockEmail = meta.getProfileLockEmail(profile.id);
const tooltipContent = isRunningRemotely
? meta.t("profiles.remote.runningTooltip")
: isPendingRemotePull
? meta.t("profiles.remote.pendingSyncTooltip")
: isLockedByAnother
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
: meta.browserState.getLaunchTooltipContent(profile);
const handleProfileStop = async (profile: BrowserProfile) => {
meta.setStoppingProfiles((prev: Set<string>) =>
new Set(prev).add(profile.id),
);
try {
await meta.onKillProfile(profile);
} catch (error) {
meta.setStoppingProfiles((prev: Set<string>) => {
const next = new Set(prev);
next.delete(profile.id);
return next;
});
throw error;
}
};
const handleProfileLaunch = async (profile: BrowserProfile) => {
meta.setLaunchingProfiles((prev: Set<string>) =>
new Set(prev).add(profile.id),
);
try {
await meta.onLaunchProfile(profile);
} finally {
// Always clear launching state — the running state is tracked
// separately via profile-running-changed events
meta.setLaunchingProfiles((prev: Set<string>) => {
const next = new Set(prev);
next.delete(profile.id);
return next;
});
}
};
const syncInfo = meta.getProfileSyncInfo(profile.id);
const isLeader = syncInfo?.isLeader === true;
const isFollower = syncInfo?.isLeader === false;
const isDesynced = isFollower && syncInfo.failedAtUrl != null;
const stopTooltip = isLeader
? meta.t("profiles.synchronizer.stopLeader")
: isFollower
? meta.t("profiles.synchronizer.stopFollower", {
leaderName: syncInfo.session.leader_profile_name ?? "",
})
: tooltipContent;
const handleStop = async () => {
if (isLeader && syncInfo) {
// Stop leader: invoke stop_sync_session which kills leader + all followers
try {
await invoke("stop_sync_session", {
sessionId: syncInfo.session.id,
});
} catch (error) {
console.error("Failed to stop sync session:", error);
}
} else if (isFollower && syncInfo) {
// Stop follower: remove from session
try {
await invoke("remove_sync_follower", {
sessionId: syncInfo.session.id,
followerProfileId: profile.id,
});
} catch (error) {
console.error("Failed to remove sync follower:", error);
}
} else {
await handleProfileStop(profile);
}
};
return (
<div className="flex items-center gap-2">
{isDesynced && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="size-4 text-warning-text" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("profiles.synchronizer.desyncedTooltip", {
url: syncInfo?.failedAtUrl ?? "",
})}
</TooltipContent>
</Tooltip>
)}
<LaunchControlTooltip
profileId={profile.id}
launching={isLaunching}
content={isRunning ? stopTooltip : tooltipContent}
>
<span className="inline-flex">
<RippleButton
variant="soft"
size="sm"
data-running={isRunning ? "true" : undefined}
disabled={!canLaunch || isLaunching || isStopping}
aria-label={
isRunning
? meta.t("profiles.actions.stop")
: meta.t("profiles.actions.launch")
}
className={cn(
"grid size-7 place-items-center rounded-full p-0 transition-[background-color,color,transform] duration-150",
!canLaunch && "cursor-not-allowed opacity-50",
canLaunch && "cursor-pointer",
!isRunning &&
"hover:bg-primary hover:text-primary-foreground",
isRunning &&
!isFollower &&
"bg-destructive/12 text-destructive-text hover:bg-destructive/20",
)}
onClick={() =>
isRunning
? void handleStop()
: void handleProfileLaunch(profile)
}
>
<span className="grid place-items-center">
{isLaunching || isStopping ? (
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent motion-reduce:animate-none" />
) : isRunning ? (
<LuSquare className="size-3 fill-current" />
) : (
<LuPlay className="size-3 translate-x-px fill-current" />
)}
</span>
</RippleButton>
</span>
</LaunchControlTooltip>
</div>
);
},
},
{
// Hidden, sort-only column so profiles can be sorted by creation date
// without showing a Created column in the table. Kept
// hidden via columnVisibility; sorting still works on hidden columns.
id: "created_at",
accessorFn: (row) => row.created_at ?? 0,
enableSorting: true,
enableHiding: true,
sortingFn: "basic",
header: () => null,
cell: () => null,
},
{
accessorKey: "name",
// The only column without a fixed width: table-fixed hands it all
// remaining space as the window grows or shrinks.
meta: { flexWidth: true },
// The Name header doubles as the sort control: clicking opens a menu to
// sort by name (A–Z / Z–A) or by creation date (newest / oldest), so
// creation-date sorting needs no visible column.
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
const sort = table.options.state?.sorting?.[0];
const isActive = (id: string, desc: boolean) =>
sort?.id === id && !!sort.desc === desc;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
className="-ml-1.5 h-6 justify-start gap-1 rounded-md px-1.5 text-left text-xs font-medium"
>
{meta.t("common.labels.name")}
{isActive("name", false) ? (
<LuArrowUp className="size-3" />
) : isActive("name", true) ? (
<LuArrowDown className="size-3" />
) : (
<LuArrowUpDown className="size-3 opacity-60" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() =>
table.setSorting([{ id: "name", desc: false }])
}
>
{isActive("name", false) && (
<LuCheck className="mr-2 size-3.5" />
)}
{meta.t("profiles.sort.nameAsc")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => table.setSorting([{ id: "name", desc: true }])}
>
{isActive("name", true) && (
<LuCheck className="mr-2 size-3.5" />
)}
{meta.t("profiles.sort.nameDesc")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
table.setSorting([{ id: "created_at", desc: true }])
}
>
{isActive("created_at", true) && (
<LuCheck className="mr-2 size-3.5" />
)}
{meta.t("profiles.sort.newest")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
table.setSorting([{ id: "created_at", desc: false }])
}
>
{isActive("created_at", false) && (
<LuCheck className="mr-2 size-3.5" />
)}
{meta.t("profiles.sort.oldest")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original as BrowserProfile;
const rawName: string = row.getValue("name");
const name = getBrowserDisplayName(rawName);
const isRuntimeLocked =
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
meta.launchingProfiles.has(profile.id) ||
meta.stoppingProfiles.has(profile.id);
const isCrossOsBlocked = isCrossOsProfile(profile);
const isEditing =
meta.profileToRename?.id === profile.id &&
!isRuntimeLocked &&
!isCrossOsBlocked;
if (isEditing) {
return (
<div
ref={renameContainerRef}
className="relative overflow-visible"
>
<Input
autoFocus
value={meta.newProfileName}
onChange={(e) => {
meta.setNewProfileName(e.target.value);
if (meta.renameError) meta.setRenameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Escape") {
meta.setProfileToRename(null);
meta.setNewProfileName("");
meta.setRenameError(null);
}
}}
onBlur={() => {
if (
meta.newProfileName.trim().length > 0 &&
meta.newProfileName.trim() !== profile.name
) {
void meta.handleRename();
} else {
meta.setProfileToRename(null);
meta.setNewProfileName("");
meta.setRenameError(null);
}
}}
className="h-6 w-full max-w-full min-w-0 border-0 px-2 py-1 text-sm leading-none font-medium shadow-none focus-visible:ring-0"
/>
</div>
);
}
const display = (
<OverflowTooltipText
text={name}
className="text-left leading-none font-medium"
/>
);
const lockedEmail = meta.getProfileLockEmail(profile.id);
const isLocked = meta.isProfileLockedByAnother(profile.id);
const dragProfiles = meta.selectedProfiles.includes(profile.id)
? meta.selectedDragProfiles
: [profile];
const canDrag = () => meta.canDragProfiles(dragProfiles);
const nameControl = isRuntimeLocked ? (
<div className="mr-auto h-6 max-w-full min-w-0 cursor-text overflow-hidden rounded px-2 py-1 text-left select-text">
{display}
</div>
) : (
<button
type="button"
className={cn(
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded-md border-none bg-transparent px-2 py-1 text-left transition-colors duration-100",
isCrossOsBlocked
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-foreground/6",
)}
onClick={() => {
if (isCrossOsBlocked) return;
meta.setProfileToRename(profile);
meta.setNewProfileName(profile.name);
meta.setRenameError(null);
}}
onKeyDown={(e) => {
if (isCrossOsBlocked) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
meta.setProfileToRename(profile);
meta.setNewProfileName(profile.name);
meta.setRenameError(null);
}
}}
>
{display}
</button>
);
return (
<div className="-ml-1 flex max-w-full min-w-0 items-center gap-0.5">
<ProfileGroupDragHandle
profile={profile}
profiles={dragProfiles}
disabled={!canDrag()}
canMove={meta.canDragProfiles}
onChooseGroup={() =>
meta.onAssignProfilesToGroup?.(
dragProfiles.map((target) => target.id),
)
}
/>
{nameControl}
<ProfileHandoffStatus
profileId={profile.id}
state={meta.getRemoteHandoff(profile.id)}
syncStatus={meta.syncStatuses[profile.id]}
compact
onOpenSync={
meta.onOpenProfileSyncDialog
? () => meta.onOpenProfileSyncDialog?.(profile)
: undefined
}
/>
{isLocked && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuLock className="size-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("sync.team.profileLocked", { email: lockedEmail })}
</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
{
id: "profileId",
size: 100,
enableSorting: false,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.profileId");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <ProfileIdCell profile={row.original} meta={meta} />;
},
},
{
id: "state",
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileStatuses.column");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <StatusCell profile={row.original} meta={meta} />;
},
},
{
id: "tags",
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isDisabled = isCrossOsProfile(profile);
return (
<TagsCell
profile={profile}
isDisabled={isDisabled}
tagsOverrides={meta.tagsOverrides ?? {}}
allTags={meta.allTags ?? []}
setAllTags={meta.setAllTags}
openTagsEditorFor={meta.openTagsEditorFor ?? null}
setOpenTagsEditorFor={meta.setOpenTagsEditorFor}
setTagsOverrides={meta.setTagsOverrides}
/>
);
},
},
{
id: "note",
size: 80,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isDisabled = isCrossOsProfile(profile);
return (
<NoteCell
profile={profile}
isDisabled={isDisabled}
noteOverrides={meta.noteOverrides ?? {}}
openNoteEditorFor={meta.openNoteEditorFor ?? null}
setOpenNoteEditorFor={meta.setOpenNoteEditorFor}
setNoteOverrides={meta.setNoteOverrides}
/>
);
},
},
{
id: "proxy",
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
profile.id,
);
const effectiveProxyId = hasProxyOverride
? meta.proxyOverrides[profile.id]
: (profile.proxy_id ?? null);
const effectiveProxy = effectiveProxyId
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
null)
: null;
const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id);
const effectiveVpnId = hasVpnOverride
? meta.vpnOverrides[profile.id]
: (profile.vpn_id ?? null);
const effectiveVpn = effectiveVpnId
? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null)
: null;
const hasAssignment = Boolean(effectiveProxy || effectiveVpn);
const displayName = effectiveVpn
? effectiveVpn.name
: effectiveProxy
? effectiveProxy.name
: meta.t("profiles.table.none");
const vpnBadge = effectiveVpn ? "WG" : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
// When profile is running, show bandwidth chart instead of proxy selector
if (isRunning && meta.trafficSnapshots) {
const snapshot = meta.trafficSnapshots[profile.id];
const bandwidthData = snapshot?.recent_bandwidth
? [...snapshot.recent_bandwidth]
: [];
const currentBandwidth =
(snapshot?.current_bytes_sent ?? 0) +
(snapshot?.current_bytes_received ?? 0);
return (
<div className="min-w-0 overflow-hidden">
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<Popover
open={isSelectorOpen}
onOpenChange={(open) => {
meta.setOpenProxySelectorFor(open ? profile.id : null);
}}
>
<ProxyCellTrigger
displayName={displayName}
hasAssignment={hasAssignment}
vpnBadge={vpnBadge}
isDisabled={isDisabled}
/>
{!isDisabled && (
<PopoverContent
className="w-[240px] p-0"
align="end"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={
meta.canCreateLocationProxy
? t("createProfile.proxy.searchWithCountries")
: t("createProfile.proxy.search")
}
onFocus={() => {
if (meta.canCreateLocationProxy)
void meta.loadCountries();
}}
/>
<CommandList>
<CommandEmpty>
{t("createProfile.proxy.notFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() =>
void meta.handleProxySelection(profile.id, null)
}
>
<LuCheck
className={cn(
"mr-2 size-4",
selectedId === null
? "opacity-100"
: "opacity-0",
)}
/>
{t("common.labels.none")}
</CommandItem>
{meta.storedProxies
.filter(
(proxy: StoredProxy) =>
!proxy.is_cloud_managed &&
!proxy.is_cloud_derived,
)
.map((proxy: StoredProxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 size-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading={t("profileTable.vpnsHeading")}>
{meta.vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() =>
void meta.handleVpnSelection(
profile.id,
vpn.id,
)
}
>
<LuCheck
className={cn(
"mr-2 size-4",
effectiveVpnId === vpn.id
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="soft"
className="mr-1 px-1 py-0 text-[10px] leading-tight"
>
WG
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
{meta.canCreateLocationProxy &&
meta.countries.length > 0 && (
<CommandGroup
heading={t("profileTable.createByCountryHeading")}
>
{meta.countries
.filter(
(c) =>
!meta.storedProxies.some(
(p) =>
p.is_cloud_derived &&
p.geo_country === c.code,
),
)
.map((country) => (
<CommandItem
key={`country-${country.code}`}
value={`create-${country.name}`}
onSelect={() =>
void meta.handleCreateCountryProxy(
profile.id,
country,
)
}
>
<span className="mr-2 size-4" />+{" "}
{country.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{effectiveProxy && !effectiveVpn && !isDisabled && (
<ProxyCheckButton proxy={effectiveProxy} />
)}
</div>
);
},
},
{
id: "ext",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.ext");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <ExtCell profile={profile} meta={meta} />;
},
},
{
id: "dns",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.dns");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "bot",
size: 84,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
if (meta.cookieBotCompact) return null;
return meta.t("profiles.table.bot");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <BotCell profile={row.original} meta={meta} />;
},
},
{
id: "sync",
header: "",
size: 28,
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
const syncEntry = meta.syncStatuses[profile.id];
const liveStatus = syncEntry?.status as
| "syncing"
| "waiting"
| "synced"
| "error"
| "disabled"
| undefined;
const dot = getProfileSyncStatusDot(
profile,
liveStatus,
meta.t,
syncEntry?.error,
);
if (!dot) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-9 w-full items-center justify-center">
{dot.encrypted ? (
<LuLock
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
/>
) : (
<span
className={`size-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent>{dot.tooltip}</TooltipContent>
</Tooltip>
);
},
},
{
id: "settings",
size: 32,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return (
<div className="flex h-9 w-full items-center justify-end">
<Button
variant="subtle"
className="size-7 rounded-md p-0"
data-slot="profile-inspect-trigger"
data-profile-id={profile.id}
{...tipAnchor("row-inspect")}
disabled={!meta.isClient}
onClick={(event) => {
const method = event.detail === 0 ? "keyboard" : "pointer";
setInternalInfoOpenMethod(method);
onInfoOpenMethodChange?.(method);
setProfileForInfoDialog(profile);
}}
>
<span className="sr-only">
{t("profiles.aria.profileInfo")}
</span>
<LuInfo className="size-4" />
</Button>
</div>
);
},
},
],
[t, setProfileForInfoDialog, onInfoOpenMethodChange],
);
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
// `bot` and `profileId` start hidden and are switched on by the resize effect
// below. An unentitled account must never see a paid column, not even for the
// frame before the observer's first measurement lands, and the id must not
// flash into a narrow table for that same frame.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({
created_at: false,
bot: false,
profileId: false,
});
const columnLabels = React.useMemo(
() => [
{ id: "name", label: t("common.labels.name"), hideable: false },
{ id: "created_at", label: t("search.fields.created"), hideable: false },
{ id: "profileId", label: t("profiles.table.profileId") },
{ id: "state", label: t("profileStatuses.column") },
{ id: "tags", label: t("profileTable.tagsHeader") },
{ id: "note", label: t("profileTable.noteHeader") },
{ id: "proxy", label: t("profiles.table.proxy") },
{ id: "ext", label: t("profiles.table.ext") },
{ id: "dns", label: t("profiles.table.dns") },
...(cookieBotUnlocked
? [{ id: "bot", label: t("profiles.table.bot") }]
: []),
],
[t, cookieBotUnlocked],
);
const configuredColumns = React.useMemo<ColumnDef<BrowserProfile>[]>(
() =>
columns.map((column) => {
const id =
column.id ??
("accessorKey" in column ? String(column.accessorKey) : "");
const fixed = ["select", "actions", "settings", "sync", "bot"].includes(
id,
);
const accessor = (row: BrowserProfile) => {
if (id === "tags") return (row.tags ?? []).join(", ");
if (id === "note") return row.note ?? "";
if (id === "proxy")
return (
storedProxies.find((proxy) => proxy.id === row.proxy_id)?.name ??
vpnConfigs.find((vpn) => vpn.id === row.vpn_id)?.name ??
""
);
if (id === "ext")
return (
extensionGroups.find(
(group) => group.id === row.extension_group_id,
)?.name ?? ""
);
if (id === "dns") return row.dns_blocklist ?? "";
if (id === "state")
return (
profileStatuses.find((status) => status.id === row.status_id)
?.name ?? ""
);
return row.id;
};
return {
...column,
id,
enableResizing: !fixed && id !== "created_at",
minSize: fixed ? column.size : 48,
maxSize: 1600,
...([
"state",
"tags",
"note",
"proxy",
"ext",
"dns",
"profileId",
].includes(id)
? {
accessorFn: accessor,
enableSorting: true,
sortingFn: "alphanumeric" as const,
}
: {}),
};
}),
[columns, storedProxies, vpnConfigs, extensionGroups, profileStatuses],
);
const table = useReactTable({
data: profiles,
columns: configuredColumns,
state: {
sorting,
rowSelection,
columnVisibility: resolveTableVisibility(
columnVisibility,
view.preferences.visibility,
["select", "actions", "name", "settings"],
cookieBotUnlocked ? ["created_at"] : ["created_at", "bot"],
),
columnSizing: view.preferences.sizing,
},
onSortingChange: view.setSorting,
onColumnSizingChange: view.setSizing,
columnResizeMode: "onChange",
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: view.setVisibility,
enableRowSelection: (row) => {
const profile = row.original;
const isRunning =
browserState.isClient && runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
return !isRunning && !isLaunching && !isStopping;
},
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
meta: tableMeta,
});
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const workspaceRef = React.useRef<HTMLDivElement | null>(null);
const [inspectorLayout, setInspectorLayout] = React.useState(false);
React.useEffect(() => {
const element = workspaceRef.current;
if (!element) return;
const update = () => setInspectorLayout(element.clientWidth >= 1200);
update();
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, []);
const columnWidth = React.useCallback(
(id: string, sizePx: number) => {
if (view.preferences.sizing[id])
return `${view.preferences.sizing[id]}px`;
// The bot column is the one column with two shapes: a labelled state at
// full width, a bare mark when the table is narrow. Taking a proportion
// in the compact shape would waste the space the name column needs.
if (id === "bot") {
return containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH
? "28px"
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
}
const proportions: Record<string, { pct: number; floor: number }> = {
state: { pct: 0.1, floor: 90 },
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
proxy: { pct: 0.13, floor: 110 },
ext: { pct: 0.11, floor: 95 },
dns: { pct: 0.11, floor: 95 },
};
const p = proportions[id];
if (!p) return `${sizePx}px`;
return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`;
},
[containerWidth, view.preferences.sizing],
);
const sortedRows = table.getRowModel().rows;
useScrollFade(scrollParentRef);
React.useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
const update = () => {
const w = el.clientWidth;
setContainerWidth(Math.round(w / 8) * 8);
setColumnVisibility((prev) => {
const next: VisibilityState = {
// Always hidden — sort-only column.
created_at: false,
// First to leave: pure metadata, and the info dialog still has it.
profileId: w >= PROFILE_ID_MIN_WIDTH,
dns: w >= 768,
ext: w >= 672,
state: w >= 560,
note: w >= 576,
tags: w >= 512,
// Bot state survives further down than the other content columns:
// by then it is a 28px mark, and it is the only place a row's
// enrolment and its actions can be reached.
bot: cookieBotUnlocked && w >= BOT_COLUMN_MIN_WIDTH,
};
return Object.keys(next).every((k) => prev[k] === next[k])
? prev
: next;
});
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
ro.disconnect();
};
}, [cookieBotUnlocked]);
// The virtual row estimate must match the selected row height.
const ROW_HEIGHT = view.preferences.density === "comfortable" ? 48 : 36;
const rowVirtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const measuredRowHeight = React.useRef(ROW_HEIGHT);
React.useEffect(() => {
if (measuredRowHeight.current !== ROW_HEIGHT) {
measuredRowHeight.current = ROW_HEIGHT;
rowVirtualizer.measure();
}
}, [ROW_HEIGHT, rowVirtualizer]);
const keyboard = useTableKeyboard(table, {
scrollToIndex: (index) =>
rowVirtualizer.scrollToIndex(index, { align: "auto" }),
edit: (rowId, columnId) => {
if (columnId !== "name") return false;
const profile = profiles.find((item) => item.id === rowId);
if (
!profile ||
isCrossOsProfile(profile) ||
runningProfiles.has(rowId) ||
launchingProfiles.has(rowId) ||
stoppingProfiles.has(rowId)
)
return true;
setProfileToRename(profile);
setNewProfileName(profile.name);
setRenameError(null);
return true;
},
});
const minimumWidth =
Object.keys(view.preferences.sizing).length ||
Object.values(view.preferences.visibility).some(Boolean)
? table
.getVisibleLeafColumns()
.reduce(
(total, column) =>
total +
(column.id === "name" && !view.preferences.sizing.name
? 220
: parseFloat(columnWidth(column.id, column.getSize()))),
0,
)
: undefined;
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<>
<div className="mb-3 flex shrink-0 flex-wrap items-start justify-between gap-2">
{filterControls}
<TableViewControls table={table} view={view} labels={columnLabels} />
</div>
<div
ref={workspaceRef}
data-slot="profile-workspace"
className="relative flex min-h-0 min-w-0 flex-1 gap-3"
>
<div
ref={scrollParentRef}
className={cn(
"scroll-fade relative min-h-0 min-w-0 flex-1 overflow-auto",
table.getFilteredSelectedRowModel().rows.length > 0 &&
"pb-[calc(var(--table-action-bar-height,56px)+32px)]",
)}
style={
{
// Sticky table header is 32px tall (h-8); shift the top
// fade band below it so the header stays fully opaque and
// only body rows fade as they scroll past.
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table
ref={keyboard.tableRef}
role="grid"
aria-label={t("profiles.title")}
aria-rowcount={sortedRows.length + 1}
aria-colcount={table.getVisibleLeafColumns().length}
onKeyDown={keyboard.onKeyDown}
onKeyDownCapture={keyboard.onKeyDownCapture}
className={DATA_TABLE_CLASSES.table}
style={{ minWidth: minimumWidth, width: minimumWidth }}
containerClassName="overflow-visible"
>
<TableHeader
className={cn(DATA_TABLE_CLASSES.header, "overflow-visible")}
>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className={cn(
DATA_TABLE_CLASSES.headerRow,
"overflow-visible",
)}
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
role="columnheader"
{...columnAnchor(header.column.id)}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: undefined
}
className={DATA_TABLE_CLASSES.head}
style={{
width:
header.column.columnDef.meta?.flexWidth &&
!view.preferences.sizing[header.column.id]
? undefined
: columnWidth(
header.column.id,
header.column.getSize(),
),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<TableColumnResizer
header={header}
label={
columnLabels.find(
(column) => column.id === header.column.id,
)?.label ?? ""
}
disabled={!view.loaded}
/>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{sortedRows.length === 0 ? (
isLoading ? (
Array.from({ length: 8 }, (_, i) => (
<TableRow
key={`skeleton-${i}`}
className={DATA_TABLE_CLASSES.row}
style={{ height: `${ROW_HEIGHT}px` }}
>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-0"
>
<div className="flex items-center gap-3">
<Skeleton className="size-7 shrink-0 rounded-md" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
) : showOnboardingEmptyState ? (
<TableRow className={DATA_TABLE_CLASSES.row}>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-16"
>
<div className="flex flex-col items-center gap-3 text-center">
<Logo className="size-12 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">
{t("profiles.table.emptyTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profiles.table.emptyHint")}
</p>
</div>
<div className="mt-1 flex gap-2">
{onCreateProfile && (
<RippleButton size="sm" onClick={onCreateProfile}>
{t("profiles.table.emptyCreate")}
</RippleButton>
)}
{onImportProfiles && (
<RippleButton
size="sm"
variant="soft"
onClick={onImportProfiles}
>
{t("profiles.table.emptyImport")}
</RippleButton>
)}
</div>
</div>
</TableCell>
</TableRow>
) : (
<TableRow className={DATA_TABLE_CLASSES.row}>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-16"
>
<div className="flex flex-col items-center gap-3 text-center">
<div className="grid size-12 place-items-center rounded-full bg-foreground/6">
<LuUserSearch className="size-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{t("profiles.table.emptyFilteredTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profiles.table.emptyFilteredHint")}
</p>
</div>
{onClearFilters && (
<RippleButton
size="sm"
variant="soft"
className="mt-1"
onClick={onClearFilters}
>
{t("tables.clearFilters")}
</RippleButton>
)}
</div>
</TableCell>
</TableRow>
)
) : (
<>
{paddingTop > 0 && (
<tr
aria-hidden="true"
tabIndex={-1}
style={{ height: `${paddingTop}px` }}
>
<td colSpan={table.getVisibleLeafColumns().length} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = sortedRows[virtualRow.index];
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
role="row"
aria-rowindex={virtualRow.index + 2}
aria-selected={row.getIsSelected()}
data-table-row={row.id}
data-profile-id={row.id}
data-inspected={
profileForInfoDialog?.id === row.id
? "true"
: undefined
}
data-moving={
profileDrag?.movingIds.has(row.id)
? "true"
: undefined
}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
DATA_TABLE_CLASSES.row,
"overflow-visible",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell, columnIndex) => (
<TableCell
key={cell.id}
role="gridcell"
aria-colindex={columnIndex + 1}
{...keyboard.cellProps(row.id, cell.column.id)}
className={cn(
DATA_TABLE_CLASSES.cell,
"overflow-visible",
)}
style={{
width:
cell.column.columnDef.meta?.flexWidth &&
!view.preferences.sizing[cell.column.id]
? undefined
: columnWidth(
cell.column.id,
cell.column.getSize(),
),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<tr
aria-hidden="true"
tabIndex={-1}
style={{ height: `${paddingBottom}px` }}
>
<td colSpan={table.getVisibleLeafColumns().length} />
</tr>
)}
</>
)}
</TableBody>
</Table>
</div>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => {
setProfileToDelete(null);
}}
onConfirm={handleDelete}
title={t("profiles.delete.title")}
description={t("profiles.delete.description", {
profileName: profileToDelete?.name ?? "",
})}
confirmButtonText={t("profiles.delete.confirmButton")}
isLoading={isDeleting}
/>
{profileForInfoDialog &&
(() => {
const infoProfile =
allProfiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
const infoIsStopping = stoppingProfiles.has(infoProfile.id);
const infoIsCrossOs = isCrossOsProfile(infoProfile);
const infoIsDisabled =
infoIsRunning ||
infoIsLaunching ||
infoIsStopping ||
infoIsCrossOs;
return (
<ProfileInfoDialog
presentation={inspectorLayout ? "inspector" : "modal"}
openMethod={infoOpenMethod ?? internalInfoOpenMethod}
handoffState={handoffFor(infoProfile.id)}
isOpen={profileForInfoDialog !== null}
onClose={() => {
setProfileForInfoDialog(null);
}}
profile={infoProfile}
storedProxies={storedProxies}
vpnConfigs={vpnConfigs}
onOpenTrafficDialog={(profileId) => {
const profile = profiles.find((p) => p.id === profileId);
setTrafficDialogProfile({
id: profileId,
name: profile?.name,
});
}}
onOpenProfileSyncDialog={onOpenProfileSyncDialog}
onAssignProfilesToGroup={onAssignProfilesToGroup}
onConfigureWayfern={onConfigureWayfern}
onCopyCookiesToProfile={onCopyCookiesToProfile}
onOpenCookieManagement={onOpenCookieManagement}
onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => {
setBypassRulesProfile(profile);
}}
onOpenDnsBlocklist={(profile) => {
setDnsBlocklistProfile(profile);
}}
onOpenLaunchHook={(profile) => {
setLaunchHookProfile(profile);
}}
onCloneProfile={onCloneProfile}
onSaveAsTemplate={setTemplateProfile}
onLaunchWithSync={onLaunchWithSync}
onSetPassword={onSetPassword}
onChangePassword={onChangePassword}
onRemovePassword={onRemovePassword}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
}}
crossOsUnlocked={crossOsUnlocked}
isRunning={infoIsRunning}
isDisabled={infoIsDisabled}
isCrossOs={infoIsCrossOs}
syncStatuses={syncStatuses}
/>
);
})()}
</div>
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
{onBulkRun && (
<span className="relative inline-flex">
<DataTableActionBarAction
tooltip={
bulkActionsUnlocked
? t("profiles.actionBar.runSelected")
: t("profiles.actionBar.proRequired")
}
label={t("profiles.actionBar.runSelected")}
onClick={bulkActionsUnlocked ? onBulkRun : undefined}
disabled={!bulkActionsUnlocked}
>
<LuPlay className="fill-current" />
</DataTableActionBarAction>
{!bulkActionsUnlocked && (
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
)}
</span>
)}
{onBulkStop && (
<span className="relative inline-flex">
<DataTableActionBarAction
tooltip={
bulkActionsUnlocked
? t("profiles.actionBar.stopSelected")
: t("profiles.actionBar.proRequired")
}
label={t("profiles.actionBar.stopSelected")}
onClick={bulkActionsUnlocked ? onBulkStop : undefined}
disabled={!bulkActionsUnlocked}
>
<LuSquare className="fill-current" />
</DataTableActionBarAction>
{!bulkActionsUnlocked && (
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
)}
</span>
)}
<DataTableActionBarAction
tooltip={t("bulkEdit.action")}
onClick={() => setBulkEditOpen(true)}
>
<LuSlidersHorizontal />
</DataTableActionBarAction>
{onBulkGroupAssignment && (
<DataTableActionBarAction
tooltip={t("profiles.actionBar.assignToGroup")}
onClick={onBulkGroupAssignment}
>
<LuUsers />
</DataTableActionBarAction>
)}
{onBulkProxyAssignment && (
<DataTableActionBarAction
tooltip={t("profiles.actionBar.assignProxy")}
onClick={onBulkProxyAssignment}
>
<FiWifi />
</DataTableActionBarAction>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="soft" size="sm" className="h-7 rounded-lg text-xs">
{t("tables.moreActions")}
<LuChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onBulkProxyDistribution && (
<DropdownMenuItem onSelect={onBulkProxyDistribution}>
<LuShuffle />
{t("profiles.actionBar.distributeProxies")}
</DropdownMenuItem>
)}
{onBulkExtensionGroupAssignment && (
<DropdownMenuItem onSelect={onBulkExtensionGroupAssignment}>
<LuPuzzle />
{t("profiles.actionBar.assignExtensionGroup")}
</DropdownMenuItem>
)}
{onBulkCopyCookies && (
<DropdownMenuItem onSelect={onBulkCopyCookies}>
<LuCookie />
{t("profiles.actionBar.copyCookies")}
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!cookieBotUnlocked}
onSelect={() => {
if (cookieBotUnlocked) handleBulkCookieBotEnrol();
}}
>
<LuMoon />
{t("cookieBot.actionBar.enrol")}
{!cookieBotUnlocked && <ProBadge />}
</DropdownMenuItem>
{onBulkDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={onBulkDelete}
>
<LuTrash2 />
{t("common.buttons.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</DataTableActionBar>
{trafficDialogProfile && (
<TrafficDetailsDialog
isOpen={trafficDialogProfile !== null}
onClose={() => {
setTrafficDialogProfile(null);
}}
profileId={trafficDialogProfile.id}
profileName={trafficDialogProfile.name}
/>
)}
<ProfileBypassRulesDialog
isOpen={bypassRulesProfile !== null}
onClose={() => {
setBypassRulesProfile(null);
}}
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
<SaveTemplateDialog
profile={templateProfile}
onClose={() => setTemplateProfile(null)}
/>
<BulkEditDialog
isOpen={bulkEditOpen}
onClose={() => setBulkEditOpen(false)}
profileIds={selectedProfiles}
statuses={profileStatuses}
/>
<ProfileStatusesDialog
isOpen={statusesDialogOpen}
onClose={() => setStatusesDialogOpen(false)}
statuses={profileStatuses}
profiles={allProfiles}
/>
<ProfileDnsBlocklistDialog
isOpen={dnsBlocklistProfile !== null}
onClose={() => {
setDnsBlocklistProfile(null);
}}
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}
/>
{botScheduleDialog && (
<CookieBotEnrolDialog
isOpen
onClose={() => {
setBotScheduleDialog(null);
}}
profiles={botScheduleDialog.profiles}
existing={botScheduleDialog.existing}
onOpenProfileSync={onOpenProfileSyncDialog}
// "A proxy or VPN is required" is the precondition most profiles
// fail, and without this the dialog showed the reason with no way to
// act on it — one-click fixable from the Cookie Bot page and a dead
// end from the row menu that is the primary entry point.
onAssignProxy={onAssignProfilesToProxy}
onSaved={() => {
// Clearing after a bulk write mirrors the other bulk actions: the
// selection has been acted on, and leaving it live invites a second
// pass over profiles that are already enrolled.
if (botScheduleDialog.profiles.length > 1) {
onSelectedProfilesChange([]);
}
}}
/>
)}
<DeleteConfirmationDialog
isOpen={pendingBulkEnrol !== null}
onClose={() => {
setPendingBulkEnrol(null);
}}
onConfirm={() => {
if (!pendingBulkEnrol) return;
setBotScheduleDialog({
profiles: pendingBulkEnrol,
existing: null,
});
setPendingBulkEnrol(null);
}}
title={t("cookieBot.enrol.confirmBulkTitle", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
description={t("cookieBot.enrol.confirmBulkDescription", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonText={t("cookieBot.enrol.confirmBulkButton", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonVariant="default"
profileIds={pendingBulkEnrol
?.filter((p) => preflight(p).eligible)
.map((p) => p.id)}
profiles={pendingBulkEnrol?.map((p) => ({ id: p.id, name: p.name }))}
/>
<CookieBotRunsDialog
isOpen={botRunsProfile !== null}
onClose={() => {
setBotRunsProfile(null);
}}
profileId={botRunsProfile?.id ?? null}
profileName={botRunsProfile?.name}
onRunCancelled={() => {
void refreshCookieBotState();
}}
/>
<DeleteConfirmationDialog
isOpen={botUnenrolProfile !== null}
onClose={() => {
setBotUnenrolProfile(null);
}}
onConfirm={handleBotUnenrol}
title={t("cookieBot.schedule.unenrolTitle", {
name: botUnenrolProfile?.name ?? "",
})}
description={t("cookieBot.schedule.unenrolDescription")}
confirmButtonText={t("cookieBot.schedule.unenrol")}
isLoading={isUnenrolling}
/>
</>
);
}