mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 01:37:51 +02:00
1515 lines
50 KiB
TypeScript
1515 lines
50 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type ColumnDef,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
type SortingState,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { emit, listen } from "@tauri-apps/api/event";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import * as React from "react";
|
|
import { IoEllipsisHorizontal } from "react-icons/io5";
|
|
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
|
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useBrowserState } from "@/hooks/use-browser-state";
|
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
|
import { useTableSorting } from "@/hooks/use-table-sorting";
|
|
import {
|
|
getBrowserDisplayName,
|
|
getBrowserIcon,
|
|
getCurrentOS,
|
|
} from "@/lib/browser-utils";
|
|
import { trimName } from "@/lib/name-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type { BrowserProfile, StoredProxy } from "@/types";
|
|
import { LoadingButton } from "./loading-button";
|
|
import MultipleSelector, { type Option } from "./multiple-selector";
|
|
import { Input } from "./ui/input";
|
|
import { RippleButton } from "./ui/ripple";
|
|
|
|
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
|
// causing column definitions to be recreated on every render.
|
|
type TableMeta = {
|
|
selectedProfiles: string[];
|
|
selectableCount: number;
|
|
showCheckboxes: 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[]>>
|
|
>;
|
|
|
|
// 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>;
|
|
|
|
// Selection helpers
|
|
isProfileSelected: (id: string) => boolean;
|
|
handleToggleAll: (checked: boolean) => void;
|
|
handleCheckboxChange: (id: string, checked: boolean) => void;
|
|
handleIconClick: (id: string) => void;
|
|
|
|
// Rename helpers
|
|
handleRename: () => void | Promise<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<void>;
|
|
|
|
// Overflow actions
|
|
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
|
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
|
};
|
|
|
|
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 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,
|
|
);
|
|
|
|
React.useLayoutEffect(() => {
|
|
// Only measure when not editing this profile's tags
|
|
if (openTagsEditorFor === profile.id) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let timeoutId: number | undefined;
|
|
const compute = () => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
timeoutId = window.setTimeout(() => {
|
|
const available = container.clientWidth;
|
|
if (available <= 0) return;
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const style = window.getComputedStyle(container);
|
|
const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
ctx.font = font;
|
|
const padding = 16;
|
|
const gap = 4;
|
|
let used = 0;
|
|
let count = 0;
|
|
for (let i = 0; i < effectiveTags.length; i++) {
|
|
const text = effectiveTags[i];
|
|
const width = Math.ceil(ctx.measureText(text).width) + padding;
|
|
const remaining = effectiveTags.length - (i + 1);
|
|
let extra = 0;
|
|
if (remaining > 0) {
|
|
const plusText = `+${remaining}`;
|
|
extra = Math.ceil(ctx.measureText(plusText).width) + padding;
|
|
}
|
|
const nextUsed =
|
|
used +
|
|
(used > 0 ? gap : 0) +
|
|
width +
|
|
(remaining > 0 ? gap + extra : 0);
|
|
if (nextUsed <= available) {
|
|
used += (used > 0 ? gap : 0) + width;
|
|
count = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
setVisibleCount(count);
|
|
}, 16); // Debounce with RAF timing
|
|
};
|
|
compute();
|
|
const ro = new ResizeObserver(compute);
|
|
ro.observe(container);
|
|
return () => {
|
|
ro.disconnect();
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
};
|
|
}, [effectiveTags, openTagsEditorFor, profile.id]);
|
|
|
|
React.useEffect(() => {
|
|
if (openTagsEditorFor !== profile.id) return;
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node | null;
|
|
if (
|
|
editorRef.current &&
|
|
target &&
|
|
!editorRef.current.contains(target)
|
|
) {
|
|
setOpenTagsEditorFor(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]);
|
|
|
|
React.useEffect(() => {
|
|
if (openTagsEditorFor === profile.id && editorRef.current) {
|
|
// Focus the inner input of MultipleSelector on open
|
|
const inputEl = editorRef.current.querySelector("input");
|
|
if (inputEl) {
|
|
(inputEl as HTMLInputElement).focus();
|
|
}
|
|
}
|
|
}, [openTagsEditorFor, profile.id]);
|
|
|
|
if (openTagsEditorFor !== profile.id) {
|
|
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
|
|
const ButtonContent = (
|
|
<button
|
|
type="button"
|
|
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
|
className={cn(
|
|
"flex overflow-hidden gap-1 items-center p-2.5 w-full bg-transparent rounded border-none cursor-pointer",
|
|
isDisabled ? "opacity-60" : "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
if (!isDisabled) setOpenTagsEditorFor(profile.id);
|
|
}}
|
|
>
|
|
{effectiveTags.slice(0, visibleCount).map((t) => (
|
|
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
{effectiveTags.length === 0 && (
|
|
<span className="text-muted-foreground">No tags</span>
|
|
)}
|
|
{hiddenCount > 0 && (
|
|
<Badge variant="outline" className="px-2 py-0 text-xs">
|
|
+{hiddenCount}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="w-48 h-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="secondary"
|
|
className="px-2 py-0 text-xs"
|
|
>
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-48 h-8",
|
|
isDisabled && "opacity-60 pointer-events-none",
|
|
)}
|
|
>
|
|
<div ref={editorRef}>
|
|
<MultipleSelector
|
|
value={valueOptions}
|
|
options={allOptions}
|
|
onChange={(opts) => void handleChange(opts)}
|
|
creatable
|
|
selectFirstItem={false}
|
|
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
|
className="overflow-x-hidden overflow-y-scroll h-7 bg-transparent"
|
|
badgeClassName=""
|
|
inputProps={{
|
|
className: "py-1",
|
|
onKeyDown: (e) => {
|
|
if (e.key === "Escape") setOpenTagsEditorFor(null);
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
TagsCell.displayName = "TagsCell";
|
|
|
|
interface ProfilesDataTableProps {
|
|
profiles: BrowserProfile[];
|
|
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
|
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
|
runningProfiles: Set<string>;
|
|
isUpdating: (browser: string) => boolean;
|
|
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
|
onAssignProfilesToGroup: (profileIds: string[]) => void;
|
|
selectedGroupId: string | null;
|
|
selectedProfiles: string[];
|
|
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
|
|
}
|
|
|
|
export function ProfilesDataTable({
|
|
profiles,
|
|
onLaunchProfile,
|
|
onKillProfile,
|
|
onDeleteProfile,
|
|
onRenameProfile,
|
|
onConfigureCamoufox,
|
|
runningProfiles,
|
|
isUpdating,
|
|
onAssignProfilesToGroup,
|
|
selectedProfiles,
|
|
onSelectedProfilesChange,
|
|
}: ProfilesDataTableProps) {
|
|
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
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 [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [stoppingProfiles, setStoppingProfiles] = React.useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
|
|
const { storedProxies } = useProxyEvents();
|
|
|
|
const [proxyOverrides, setProxyOverrides] = React.useState<
|
|
Record<string, string | null>
|
|
>({});
|
|
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
|
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 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 }));
|
|
// Notify other parts of the app so usage counts and lists refresh
|
|
await emit("profile-updated");
|
|
} catch (error) {
|
|
console.error("Failed to update proxy settings:", error);
|
|
} finally {
|
|
setOpenProxySelectorFor(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Use shared browser state hook
|
|
const browserState = useBrowserState(
|
|
profiles,
|
|
runningProfiles,
|
|
isUpdating,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
);
|
|
|
|
// Clear launching/stopping spinners when backend reports running status changes
|
|
React.useEffect(() => {
|
|
if (!browserState.isClient) return;
|
|
let unlisten: (() => void) | undefined;
|
|
(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;
|
|
(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]);
|
|
|
|
// 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);
|
|
const isBrowserUpdating = isUpdating(profile.browser);
|
|
|
|
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
|
|
newSet.delete(profileId);
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
}
|
|
}, [
|
|
profiles,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
browserState.isClient,
|
|
onSelectedProfilesChange,
|
|
selectedProfiles,
|
|
]);
|
|
|
|
// Update local sorting state when settings are loaded
|
|
React.useEffect(() => {
|
|
if (isLoaded && browserState.isClient) {
|
|
setSorting(getTableSorting());
|
|
}
|
|
}, [isLoaded, getTableSorting, browserState.isClient]);
|
|
|
|
// Handle sorting changes
|
|
const handleSortingChange = React.useCallback(
|
|
(updater: React.SetStateAction<SortingState>) => {
|
|
if (!browserState.isClient) return;
|
|
const newSorting =
|
|
typeof updater === "function" ? updater(sorting) : updater;
|
|
setSorting(newSorting);
|
|
updateSorting(newSorting);
|
|
},
|
|
[browserState.isClient, sorting, updateSorting],
|
|
);
|
|
|
|
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 : "Failed to rename profile",
|
|
);
|
|
} finally {
|
|
setIsRenamingSaving(false);
|
|
}
|
|
}, [profileToRename, newProfileName, onRenameProfile]);
|
|
|
|
// 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);
|
|
try {
|
|
await onDeleteProfile(profileToDelete);
|
|
setProfileToDelete(null);
|
|
} catch (error) {
|
|
console.error("Failed to delete profile:", error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Handle icon/checkbox click
|
|
const handleIconClick = React.useCallback(
|
|
(profileId: string) => {
|
|
const profile = profiles.find((p) => p.id === profileId);
|
|
if (!profile) return;
|
|
|
|
// Prevent selection of profiles whose browsers are updating
|
|
if (!browserState.canSelectProfile(profile)) {
|
|
return;
|
|
}
|
|
|
|
setShowCheckboxes(true);
|
|
const newSet = new Set(selectedProfiles);
|
|
if (newSet.has(profileId)) {
|
|
newSet.delete(profileId);
|
|
} else {
|
|
newSet.add(profileId);
|
|
}
|
|
|
|
// Hide checkboxes if no profiles are selected
|
|
if (newSet.size === 0) {
|
|
setShowCheckboxes(false);
|
|
}
|
|
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[
|
|
profiles,
|
|
browserState.canSelectProfile,
|
|
onSelectedProfilesChange,
|
|
selectedProfiles,
|
|
],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (browserState.isClient) {
|
|
void loadAllTags();
|
|
}
|
|
}, [browserState.isClient, loadAllTags]);
|
|
|
|
// Handle checkbox change
|
|
const handleCheckboxChange = React.useCallback(
|
|
(profileId: string, checked: boolean) => {
|
|
const newSet = new Set(selectedProfiles);
|
|
if (checked) {
|
|
newSet.add(profileId);
|
|
} else {
|
|
newSet.delete(profileId);
|
|
}
|
|
|
|
// Hide checkboxes if no profiles are selected
|
|
if (newSet.size === 0) {
|
|
setShowCheckboxes(false);
|
|
}
|
|
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[onSelectedProfilesChange, selectedProfiles],
|
|
);
|
|
|
|
// Handle select all checkbox
|
|
const handleToggleAll = React.useCallback(
|
|
(checked: boolean) => {
|
|
const newSet = checked
|
|
? new Set(
|
|
profiles
|
|
.filter((profile) => {
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = isUpdating(profile.browser);
|
|
return (
|
|
!isRunning &&
|
|
!isLaunching &&
|
|
!isStopping &&
|
|
!isBrowserUpdating
|
|
);
|
|
})
|
|
.map((profile) => profile.id),
|
|
)
|
|
: new Set<string>();
|
|
|
|
setShowCheckboxes(checked);
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[
|
|
profiles,
|
|
onSelectedProfilesChange,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
],
|
|
);
|
|
|
|
// Memoize selectableProfiles calculation
|
|
const selectableProfiles = React.useMemo(() => {
|
|
return profiles.filter((profile) => {
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = isUpdating(profile.browser);
|
|
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
|
|
});
|
|
}, [
|
|
profiles,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
]);
|
|
|
|
// Build table meta from volatile state so columns can stay stable
|
|
const tableMeta = React.useMemo<TableMeta>(
|
|
() => ({
|
|
selectedProfiles,
|
|
selectableCount: selectableProfiles.length,
|
|
showCheckboxes,
|
|
isClient: browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
browserState,
|
|
|
|
// Tags editor state
|
|
tagsOverrides,
|
|
allTags,
|
|
openTagsEditorFor,
|
|
setAllTags,
|
|
setOpenTagsEditorFor,
|
|
setTagsOverrides,
|
|
|
|
// Proxy selector state
|
|
openProxySelectorFor,
|
|
setOpenProxySelectorFor,
|
|
proxyOverrides,
|
|
storedProxies,
|
|
handleProxySelection,
|
|
|
|
// Selection helpers
|
|
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
|
handleToggleAll,
|
|
handleCheckboxChange,
|
|
handleIconClick,
|
|
|
|
// Rename helpers
|
|
handleRename,
|
|
setProfileToRename,
|
|
setNewProfileName,
|
|
setRenameError,
|
|
profileToRename,
|
|
newProfileName,
|
|
isRenamingSaving,
|
|
renameError,
|
|
|
|
// Launch/stop helpers
|
|
setLaunchingProfiles,
|
|
setStoppingProfiles,
|
|
onKillProfile,
|
|
onLaunchProfile,
|
|
|
|
// Overflow actions
|
|
onAssignProfilesToGroup,
|
|
onConfigureCamoufox,
|
|
}),
|
|
[
|
|
selectedProfiles,
|
|
selectableProfiles.length,
|
|
showCheckboxes,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
browserState,
|
|
tagsOverrides,
|
|
allTags,
|
|
openTagsEditorFor,
|
|
openProxySelectorFor,
|
|
proxyOverrides,
|
|
storedProxies,
|
|
handleProxySelection,
|
|
handleToggleAll,
|
|
handleCheckboxChange,
|
|
handleIconClick,
|
|
handleRename,
|
|
profileToRename,
|
|
newProfileName,
|
|
isRenamingSaving,
|
|
renameError,
|
|
onKillProfile,
|
|
onLaunchProfile,
|
|
onAssignProfilesToGroup,
|
|
onConfigureCamoufox,
|
|
],
|
|
);
|
|
|
|
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
|
() => [
|
|
{
|
|
id: "select",
|
|
header: ({ table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
return (
|
|
<span>
|
|
<Checkbox
|
|
checked={
|
|
meta.selectedProfiles.length === meta.selectableCount &&
|
|
meta.selectableCount !== 0
|
|
}
|
|
onCheckedChange={(value) => meta.handleToggleAll(!!value)}
|
|
aria-label="Select all"
|
|
className="cursor-pointer"
|
|
/>
|
|
</span>
|
|
);
|
|
},
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const browser = profile.browser;
|
|
const IconComponent = getBrowserIcon(browser);
|
|
|
|
const isSelected = meta.isProfileSelected(profile.id);
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = meta.isUpdating(browser);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
|
|
|
if (isDisabled) {
|
|
const tooltipMessage = isRunning
|
|
? "Can't modify running profile"
|
|
: isLaunching
|
|
? "Can't modify profile while launching"
|
|
: isStopping
|
|
? "Can't modify profile while stopping"
|
|
: "Can't modify profile while browser is updating";
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex justify-center items-center w-4 h-4 cursor-not-allowed">
|
|
{IconComponent && (
|
|
<IconComponent className="w-4 h-4 opacity-50" />
|
|
)}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{tooltipMessage}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
if (meta.showCheckboxes || isSelected) {
|
|
return (
|
|
<span className="flex justify-center items-center w-4 h-4">
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={(value) =>
|
|
meta.handleCheckboxChange(profile.id, !!value)
|
|
}
|
|
aria-label="Select row"
|
|
className="w-4 h-4"
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className="flex relative justify-center items-center w-4 h-4">
|
|
<button
|
|
type="button"
|
|
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
|
onClick={() => meta.handleIconClick(profile.id)}
|
|
aria-label="Select profile"
|
|
>
|
|
<span className="w-4 h-4 group">
|
|
{IconComponent && (
|
|
<IconComponent className="w-4 h-4 group-hover:hidden" />
|
|
)}
|
|
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
|
</span>
|
|
</button>
|
|
</span>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 40,
|
|
},
|
|
{
|
|
id: "actions",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const canLaunch = meta.browserState.canLaunchProfile(profile);
|
|
const tooltipContent =
|
|
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);
|
|
} catch (error) {
|
|
meta.setLaunchingProfiles((prev: Set<string>) => {
|
|
const next = new Set(prev);
|
|
next.delete(profile.id);
|
|
return next;
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-2 items-center">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex">
|
|
<RippleButton
|
|
variant={isRunning ? "destructive" : "default"}
|
|
size="sm"
|
|
disabled={!canLaunch || isLaunching || isStopping}
|
|
className={cn(
|
|
"cursor-pointer min-w-[70px]",
|
|
!canLaunch && "opacity-50",
|
|
)}
|
|
onClick={() =>
|
|
isRunning
|
|
? handleProfileStop(profile)
|
|
: handleProfileLaunch(profile)
|
|
}
|
|
>
|
|
{isLaunching || isStopping ? (
|
|
<div className="flex gap-1 items-center">
|
|
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
|
</div>
|
|
) : isRunning ? (
|
|
"Stop"
|
|
) : (
|
|
"Launch"
|
|
)}
|
|
</RippleButton>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{tooltipContent && (
|
|
<TooltipContent>{tooltipContent}</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "name",
|
|
header: ({ column }) => {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() =>
|
|
column.toggleSorting(column.getIsSorted() === "asc")
|
|
}
|
|
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
|
>
|
|
Name
|
|
{column.getIsSorted() === "asc" ? (
|
|
<LuChevronUp className="ml-2 w-4 h-4" />
|
|
) : column.getIsSorted() === "desc" ? (
|
|
<LuChevronDown className="ml-2 w-4 h-4" />
|
|
) : null}
|
|
</Button>
|
|
);
|
|
},
|
|
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 isEditing = meta.profileToRename?.id === profile.id;
|
|
|
|
if (isEditing) {
|
|
const isSaveDisabled =
|
|
meta.isRenamingSaving ||
|
|
meta.newProfileName.trim().length === 0 ||
|
|
meta.newProfileName.trim() === profile.name;
|
|
|
|
return (
|
|
<div
|
|
ref={renameContainerRef}
|
|
className="overflow-visible relative"
|
|
>
|
|
<Input
|
|
autoFocus
|
|
value={meta.newProfileName}
|
|
onChange={(e) => {
|
|
meta.setNewProfileName(e.target.value);
|
|
if (meta.renameError) meta.setRenameError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
void meta.handleRename();
|
|
} else if (e.key === "Escape") {
|
|
meta.setProfileToRename(null);
|
|
meta.setNewProfileName("");
|
|
meta.setRenameError(null);
|
|
}
|
|
}}
|
|
className="inline-block w-30"
|
|
/>
|
|
<div className="flex absolute right-0 top-full z-50 gap-1 translate-y-[30%] opacity-100 bg-black rounded-md">
|
|
<LoadingButton
|
|
isLoading={meta.isRenamingSaving}
|
|
size="sm"
|
|
variant="default"
|
|
disabled={isSaveDisabled}
|
|
className="cursor-pointer [&[disabled]]:bg-primary/80"
|
|
onClick={() => void meta.handleRename()}
|
|
>
|
|
Save
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const display =
|
|
name.length < 14 ? (
|
|
<div className="font-medium text-left">{name}</div>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>{trimName(name, 14)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"p-2 mr-auto text-left bg-transparent rounded border-none w-30",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
if (isDisabled) return;
|
|
meta.setProfileToRename(profile);
|
|
meta.setNewProfileName(profile.name);
|
|
meta.setRenameError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (isDisabled) return;
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
meta.setProfileToRename(profile);
|
|
meta.setNewProfileName(profile.name);
|
|
meta.setRenameError(null);
|
|
}
|
|
}}
|
|
>
|
|
{display}
|
|
</button>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "tags",
|
|
header: "Tags",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
|
|
|
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}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "browser",
|
|
header: ({ column }) => {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() =>
|
|
column.toggleSorting(column.getIsSorted() === "asc")
|
|
}
|
|
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
|
>
|
|
Browser
|
|
{column.getIsSorted() === "asc" ? (
|
|
<LuChevronUp className="ml-2 w-4 h-4" />
|
|
) : column.getIsSorted() === "desc" ? (
|
|
<LuChevronDown className="ml-2 w-4 h-4" />
|
|
) : null}
|
|
</Button>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const browser: string = row.getValue("browser");
|
|
const name = getBrowserDisplayName(browser);
|
|
if (name.length < 14) {
|
|
return (
|
|
<div className="flex items-center">
|
|
<span>{name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>{trimName(name, 14)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
sortingFn: (rowA, rowB, columnId) => {
|
|
const browserA: string = rowA.getValue(columnId);
|
|
const browserB: string = rowB.getValue(columnId);
|
|
return getBrowserDisplayName(browserA).localeCompare(
|
|
getBrowserDisplayName(browserB),
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "proxy",
|
|
header: "Proxy",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
|
|
|
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
|
|
const effectiveProxyId = hasOverride
|
|
? meta.proxyOverrides[profile.id]
|
|
: (profile.proxy_id ?? null);
|
|
const effectiveProxy = effectiveProxyId
|
|
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
|
|
null)
|
|
: null;
|
|
const displayName =
|
|
profile.browser === "tor-browser"
|
|
? "Not supported"
|
|
: effectiveProxy
|
|
? effectiveProxy.name
|
|
: "Not Selected";
|
|
const profileHasProxy = Boolean(effectiveProxy);
|
|
const tooltipText =
|
|
profile.browser === "tor-browser"
|
|
? "Proxies are not supported for TOR browser"
|
|
: profileHasProxy && effectiveProxy
|
|
? effectiveProxy.name
|
|
: null;
|
|
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
|
|
|
if (profile.browser === "tor-browser") {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex gap-2 items-center">
|
|
<span className="text-sm text-muted-foreground">
|
|
Not supported
|
|
</span>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{(tooltipText || displayName.length > 10) && (
|
|
<TooltipContent>{tooltipText || displayName}</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover
|
|
open={isSelectorOpen}
|
|
onOpenChange={(open) =>
|
|
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
|
}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<span
|
|
className={cn(
|
|
"flex gap-2 items-center p-2 rounded",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed pointer-events-none"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"text-sm",
|
|
!profileHasProxy && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{profileHasProxy
|
|
? trimName(displayName, 10)
|
|
: displayName}
|
|
</span>
|
|
</span>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
|
|
</Tooltip>
|
|
|
|
{!isDisabled && (
|
|
<PopoverContent className="w-[240px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="Search proxies..." />
|
|
<CommandList>
|
|
<CommandEmpty>No proxies found.</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="__none__"
|
|
onSelect={() =>
|
|
void meta.handleProxySelection(profile.id, null)
|
|
}
|
|
>
|
|
<LuCheck
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
effectiveProxyId === null
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
No Proxy
|
|
</CommandItem>
|
|
{meta.storedProxies.map((proxy) => (
|
|
<CommandItem
|
|
key={proxy.id}
|
|
value={proxy.name}
|
|
onSelect={() =>
|
|
void meta.handleProxySelection(
|
|
profile.id,
|
|
proxy.id,
|
|
)
|
|
}
|
|
>
|
|
<LuCheck
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
effectiveProxyId === proxy.id
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{proxy.name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
)}
|
|
</Popover>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "settings",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isBrowserUpdating =
|
|
meta.isClient && meta.isUpdating(profile.browser);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
|
|
|
return (
|
|
<div className="flex justify-end items-center">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="p-0 w-8 h-8"
|
|
disabled={!meta.isClient}
|
|
>
|
|
<span className="sr-only">Open menu</span>
|
|
<IoEllipsisHorizontal className="w-4 h-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
meta.onAssignProfilesToGroup?.([profile.id]);
|
|
}}
|
|
disabled={isDisabled}
|
|
>
|
|
Assign to Group
|
|
</DropdownMenuItem>
|
|
{profile.browser === "camoufox" &&
|
|
meta.onConfigureCamoufox && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
meta.onConfigureCamoufox?.(profile);
|
|
}}
|
|
disabled={isDisabled}
|
|
>
|
|
Configure Fingerprint
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setProfileToDelete(profile);
|
|
}}
|
|
disabled={isDisabled}
|
|
>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: profiles,
|
|
columns,
|
|
state: {
|
|
sorting,
|
|
},
|
|
onSortingChange: handleSortingChange,
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getRowId: (row) => row.id,
|
|
meta: tableMeta,
|
|
});
|
|
|
|
const platform = getCurrentOS();
|
|
|
|
return (
|
|
<>
|
|
<ScrollArea
|
|
className={cn(
|
|
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
|
|
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
|
)}
|
|
>
|
|
<Table className="overflow-visible">
|
|
<TableHeader className="overflow-visible">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id} className="overflow-visible">
|
|
{headerGroup.headers.map((header) => {
|
|
return (
|
|
<TableHead key={header.id}>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody className="overflow-visible">
|
|
{table.getRowModel().rows?.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() && "selected"}
|
|
className="overflow-visible hover:bg-accent/50"
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id} className="overflow-visible">
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={columns.length}
|
|
className="h-24 text-center"
|
|
>
|
|
No profiles found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</ScrollArea>
|
|
<DeleteConfirmationDialog
|
|
isOpen={profileToDelete !== null}
|
|
onClose={() => setProfileToDelete(null)}
|
|
onConfirm={handleDelete}
|
|
title="Delete Profile"
|
|
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
|
confirmButtonText="Delete Profile"
|
|
isLoading={isDeleting}
|
|
/>
|
|
</>
|
|
);
|
|
}
|