diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index d5d4e8b..800106e 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -66,7 +66,7 @@ import MultipleSelector, { type Option } from "./multiple-selector"; import { Input } from "./ui/input"; import { RippleButton } from "./ui/ripple"; -const TagsCell: React.FC<{ +const TagsCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; tagsOverrides: Record; @@ -77,190 +77,208 @@ const TagsCell: React.FC<{ setTagsOverrides: React.Dispatch< React.SetStateAction> >; -}> = ({ - profile, - isDisabled, - tagsOverrides, - allTags, - setAllTags, - openTagsEditorFor, - setOpenTagsEditorFor, - setTagsOverrides, -}) => { - const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name) - ? tagsOverrides[profile.name] - : (profile.tags ?? []); +}>( + ({ + profile, + isDisabled, + tagsOverrides, + allTags, + setAllTags, + openTagsEditorFor, + setOpenTagsEditorFor, + setTagsOverrides, + }) => { + const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name) + ? tagsOverrides[profile.name] + : (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 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 handleChange = React.useCallback( - async (opts: Option[]) => { - const newTagsRaw = opts.map((o) => o.value); - // Dedupe while preserving order - const seen = new Set(); - const newTags: string[] = []; - for (const t of newTagsRaw) { - if (!seen.has(t)) { - seen.add(t); - newTags.push(t); + const handleChange = React.useCallback( + async (opts: Option[]) => { + const newTagsRaw = opts.map((o) => o.value); + // Dedupe while preserving order + const seen = new Set(); + const newTags: string[] = []; + for (const t of newTagsRaw) { + if (!seen.has(t)) { + seen.add(t); + newTags.push(t); + } + } + setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags })); + try { + await invoke("update_profile_tags", { + profileName: profile.name, + 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.name, setAllTags, setTagsOverrides], + ); + + const containerRef = React.useRef(null); + const editorRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState( + effectiveTags.length, + ); + + React.useLayoutEffect(() => { + // Only measure when not editing this profile's tags + if (openTagsEditorFor === profile.name) 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.name]); + + React.useEffect(() => { + if (openTagsEditorFor !== profile.name) 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.name, setOpenTagsEditorFor]); + + React.useEffect(() => { + if (openTagsEditorFor === profile.name && editorRef.current) { + // Focus the inner input of MultipleSelector on open + const inputEl = editorRef.current.querySelector("input"); + if (inputEl) { + (inputEl as HTMLInputElement).focus(); } } - setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags })); - try { - await invoke("update_profile_tags", { - profileName: profile.name, - 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.name, setAllTags, setTagsOverrides], - ); + }, [openTagsEditorFor, profile.name]); - const containerRef = React.useRef(null); - const editorRef = React.useRef(null); - const [visibleCount, setVisibleCount] = React.useState( - effectiveTags.length, - ); - - React.useLayoutEffect(() => { - // Only measure when not editing this profile's tags - if (openTagsEditorFor === profile.name) return; - const container = containerRef.current; - if (!container) return; - const compute = () => { - 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); - }; - compute(); - const ro = new ResizeObserver(() => compute()); - ro.observe(container); - return () => ro.disconnect(); - }, [effectiveTags, openTagsEditorFor, profile.name]); - - React.useEffect(() => { - if (openTagsEditorFor !== profile.name) 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.name, setOpenTagsEditorFor]); - - React.useEffect(() => { - if (openTagsEditorFor === profile.name && editorRef.current) { - // Focus the inner input of MultipleSelector on open - const inputEl = editorRef.current.querySelector("input"); - if (inputEl) { - (inputEl as HTMLInputElement).focus(); - } + if (openTagsEditorFor !== profile.name) { + const hiddenCount = Math.max(0, effectiveTags.length - visibleCount); + return ( +
+ +
+ ); } - }, [openTagsEditorFor, profile.name]); - if (openTagsEditorFor !== profile.name) { - const hiddenCount = Math.max(0, effectiveTags.length - visibleCount); return ( -
- +
+
+ void handleChange(opts)} + creatable + selectFirstItem={false} + placeholder={effectiveTags.length === 0 ? "Add tags" : ""} + className="bg-transparent" + badgeClassName="" + inputProps={{ + className: "py-1", + onKeyDown: (e) => { + if (e.key === "Escape") setOpenTagsEditorFor(null); + }, + }} + /> +
); - } + }, +); - return ( -
-
- void handleChange(opts)} - creatable - selectFirstItem={false} - placeholder={effectiveTags.length === 0 ? "Add tags" : ""} - className="bg-transparent" - badgeClassName="" - inputProps={{ - className: "py-1", - onKeyDown: (e) => { - if (e.key === "Escape") setOpenTagsEditorFor(null); - }, - }} - /> -
-
- ); -}; +TagsCell.displayName = "TagsCell"; interface ProfilesDataTableProps { data: BrowserProfile[]; @@ -278,6 +296,40 @@ interface ProfilesDataTableProps { onSelectedProfilesChange?: (profiles: string[]) => void; } +interface TableMeta { + tagsOverrides: Record; + allTags: string[]; + setAllTags: React.Dispatch>; + openTagsEditorFor: string | null; + setOpenTagsEditorFor: React.Dispatch>; + setTagsOverrides: React.Dispatch< + React.SetStateAction> + >; + selectedProfiles: Set; + showCheckboxes: boolean; + browserState: { + isClient: boolean; + canLaunchProfile: (profile: BrowserProfile) => boolean; + canSelectProfile: (profile: BrowserProfile) => boolean; + getLaunchTooltipContent: (profile: BrowserProfile) => string | null; + }; + runningProfiles: Set; + launchingProfiles: Set; + stoppingProfiles: Set; + isUpdating: (browser: string) => boolean; + proxyOverrides: Record; + storedProxies: StoredProxy[]; + openProxySelectorFor: string | null; + profileToRename: BrowserProfile | null; + newProfileName: string; + renameError: string | null; + isRenamingSaving: boolean; + onLaunchProfile: (profile: BrowserProfile) => void | Promise; + onKillProfile: (profile: BrowserProfile) => void | Promise; + onConfigureCamoufox?: (profile: BrowserProfile) => void; + onAssignProfilesToGroup?: (profileNames: string[]) => void; +} + export function ProfilesDataTable({ data, onLaunchProfile, @@ -642,22 +694,55 @@ export function ProfilesDataTable({ ], ); + // Memoize selectableProfiles calculation + const selectableProfiles = React.useMemo(() => { + return filteredData.filter((profile) => { + const isRunning = + browserState.isClient && runningProfiles.has(profile.name); + const isLaunching = launchingProfiles.has(profile.name); + const isStopping = stoppingProfiles.has(profile.name); + const isBrowserUpdating = isUpdating(profile.browser); + return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating; + }); + }, [ + filteredData, + browserState.isClient, + runningProfiles, + launchingProfiles, + stoppingProfiles, + isUpdating, + ]); + + // Stable handlers that don't change on every render + const stableHandlers = React.useMemo( + () => ({ + handleToggleAll, + handleCheckboxChange, + handleIconClick, + handleProxySelection, + handleRename, + setStoppingProfiles, + setLaunchingProfiles, + setProfileToRename, + setNewProfileName, + setRenameError, + setProfileToDelete, + setOpenProxySelectorFor, + }), + [ + handleToggleAll, + handleCheckboxChange, + handleIconClick, + handleProxySelection, + handleRename, + ], + ); + const columns: ColumnDef[] = React.useMemo( () => [ { id: "select", header: () => { - const selectableProfiles = filteredData.filter((profile) => { - const isRunning = - browserState.isClient && runningProfiles.has(profile.name); - const isLaunching = launchingProfiles.has(profile.name); - const isStopping = stoppingProfiles.has(profile.name); - const isBrowserUpdating = isUpdating(profile.browser); - return ( - !isRunning && !isLaunching && !isStopping && !isBrowserUpdating - ); - }); - return ( handleToggleAll(!!value)} + onCheckedChange={(value) => + stableHandlers.handleToggleAll(!!value) + } aria-label="Select all" className="cursor-pointer" /> ); }, - cell: ({ row }) => { + cell: ({ row, table }) => { const profile = row.original; const browser = profile.browser; const IconComponent = getBrowserIcon(browser); - const isSelected = selectedProfiles.has(profile.name); + + // Get dynamic state from table meta + const tableMeta = table.options.meta as TableMeta; + const isSelected = + tableMeta?.selectedProfiles?.has(profile.name) || false; const isRunning = - browserState.isClient && runningProfiles.has(profile.name); - const isLaunching = launchingProfiles.has(profile.name); - const isStopping = stoppingProfiles.has(profile.name); - const isBrowserUpdating = isUpdating(browser); + tableMeta?.browserState?.isClient && + tableMeta?.runningProfiles?.has(profile.name); + const isLaunching = + tableMeta?.launchingProfiles?.has(profile.name) || false; + const isStopping = + tableMeta?.stoppingProfiles?.has(profile.name) || false; + const isBrowserUpdating = tableMeta?.isUpdating?.(browser) || false; const isDisabled = isRunning || isLaunching || isStopping || isBrowserUpdating; + const showCheckboxes = tableMeta?.showCheckboxes || false; // Show tooltip for disabled profiles if (isDisabled) { @@ -717,7 +812,7 @@ export function ProfilesDataTable({ - handleCheckboxChange(profile.name, !!value) + stableHandlers.handleCheckboxChange(profile.name, !!value) } aria-label="Select row" className="w-4 h-4" @@ -731,7 +826,7 @@ export function ProfilesDataTable({