"use client"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { FaChrome, FaFirefox } from "react-icons/fa"; import { GoPlus } from "react-icons/go"; import { LuExternalLink, LuPencil, LuPuzzle, LuTrash2, LuUpload, } from "react-icons/lu"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ProBadge } from "@/components/ui/pro-badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { Extension, ExtensionGroup } from "@/types"; import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; import { RippleButton } from "./ui/ripple"; type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; function getSyncStatusDot( item: { sync_enabled?: boolean; last_sync?: number }, liveStatus: SyncStatus | undefined, ): { color: string; tooltip: string; animate: boolean } { const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); switch (status) { case "syncing": return { color: "bg-warning", tooltip: "Syncing...", animate: true }; case "synced": return { color: "bg-success", tooltip: item.last_sync ? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` : "Synced", animate: false, }; case "waiting": return { color: "bg-warning", tooltip: "Waiting to sync", animate: false, }; case "error": return { color: "bg-destructive", tooltip: "Sync error", animate: false, }; default: return { color: "bg-muted-foreground", tooltip: "Not synced", animate: false, }; } } interface ExtensionManagementDialogProps { isOpen: boolean; onClose: () => void; limitedMode: boolean; } export function ExtensionManagementDialog({ isOpen, onClose, limitedMode, }: ExtensionManagementDialogProps) { const { t } = useTranslation(); const [extensions, setExtensions] = useState([]); const [extensionGroups, setExtensionGroups] = useState([]); const [isLoading, setIsLoading] = useState(false); // Extension upload state const [isUploading, setIsUploading] = useState(false); const [extensionName, setExtensionName] = useState(""); const [showUploadForm, setShowUploadForm] = useState(false); const [pendingFile, setPendingFile] = useState<{ name: string; data: number[]; } | null>(null); // Group state const [showCreateGroup, setShowCreateGroup] = useState(false); const [newGroupName, setNewGroupName] = useState(""); const [editingGroup, setEditingGroup] = useState(null); const [editGroupName, setEditGroupName] = useState(""); const [editGroupExtensionIds, setEditGroupExtensionIds] = useState( [], ); // Delete state const [extensionToDelete, setExtensionToDelete] = useState( null, ); const [groupToDelete, setGroupToDelete] = useState( null, ); const [isDeleting, setIsDeleting] = useState(false); // Edit extension state const [editingExtension, setEditingExtension] = useState( null, ); const [editExtensionName, setEditExtensionName] = useState(""); const [pendingUpdateFile, setPendingUpdateFile] = useState<{ name: string; data: number[]; } | null>(null); // Extension icons const [extensionIcons, setExtensionIcons] = useState>( {}, ); // Sync state const [extSyncStatus, setExtSyncStatus] = useState< Record >({}); const [isTogglingExtSync, setIsTogglingExtSync] = useState< Record >({}); const [isTogglingGroupSync, setIsTogglingGroupSync] = useState< Record >({}); // Tab const [activeTab, setActiveTab] = useState<"extensions" | "groups">( "extensions", ); const loadData = useCallback(async () => { if (limitedMode) return; setIsLoading(true); try { const [exts, groups] = await Promise.all([ invoke("list_extensions"), invoke("list_extension_groups"), ]); setExtensions(exts); setExtensionGroups(groups); } catch { // User may not have pro subscription setExtensions([]); setExtensionGroups([]); } finally { setIsLoading(false); } }, [limitedMode]); const loadIcons = useCallback(async (exts: Extension[]) => { const icons: Record = {}; for (const ext of exts) { try { const icon = await invoke("get_extension_icon", { extensionId: ext.id, }); if (icon) { icons[ext.id] = icon; } } catch { // Icon not available } } setExtensionIcons(icons); }, []); useEffect(() => { if (isOpen) { void loadData().then(() => { // Icons will be loaded after extensions are set }); } }, [isOpen, loadData]); useEffect(() => { if (extensions.length > 0) { void loadIcons(extensions); } }, [extensions, loadIcons]); // Listen for extension sync status events useEffect(() => { let unlisten: (() => void) | undefined; const setupListener = async () => { unlisten = await listen<{ id: string; status: string }>( "extension-sync-status", (event) => { const { id, status } = event.payload; setExtSyncStatus((prev) => ({ ...prev, [id]: status as SyncStatus, })); }, ); }; void setupListener(); return () => { unlisten?.(); }; }, []); const handleToggleExtSync = useCallback( async (ext: Extension) => { setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true })); try { await invoke("set_extension_sync_enabled", { extensionId: ext.id, enabled: !ext.sync_enabled, }); showSuccessToast( ext.sync_enabled ? t("extensions.syncDisabled") : t("extensions.syncEnabled"), ); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } finally { setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false })); } }, [loadData, t], ); const handleToggleGroupSync = useCallback( async (group: ExtensionGroup) => { setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: true })); try { await invoke("set_extension_group_sync_enabled", { extensionGroupId: group.id, enabled: !group.sync_enabled, }); showSuccessToast( group.sync_enabled ? t("extensions.syncDisabled") : t("extensions.syncEnabled"), ); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } finally { setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false })); } }, [loadData, t], ); const handleUpdateExtension = useCallback(async () => { if (!editingExtension || !editExtensionName.trim()) return; try { await invoke("update_extension", { extensionId: editingExtension.id, name: editExtensionName.trim(), fileName: pendingUpdateFile?.name ?? null, fileData: pendingUpdateFile?.data ?? null, }); showSuccessToast(t("extensions.updateSuccess")); setEditingExtension(null); setEditExtensionName(""); setPendingUpdateFile(null); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } }, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]); const handleEditFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const validExtensions = [".xpi", ".crx", ".zip"]; const isValid = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext), ); if (!isValid) { showErrorToast(t("extensions.invalidFileType")); return; } const reader = new FileReader(); reader.onload = (event) => { const arrayBuffer = event.target?.result as ArrayBuffer; const data = Array.from(new Uint8Array(arrayBuffer)); setPendingUpdateFile({ name: file.name, data }); }; reader.readAsArrayBuffer(file); e.target.value = ""; }, [t], ); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const validExtensions = [".xpi", ".crx", ".zip"]; const isValid = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext), ); if (!isValid) { showErrorToast(t("extensions.invalidFileType")); return; } const reader = new FileReader(); reader.onload = (event) => { const arrayBuffer = event.target?.result as ArrayBuffer; const data = Array.from(new Uint8Array(arrayBuffer)); const baseName = file.name .replace(/\.(xpi|crx|zip)$/i, "") .replace(/[-_]/g, " "); setExtensionName(baseName); setPendingFile({ name: file.name, data }); setShowUploadForm(true); }; reader.onerror = () => { showErrorToast(t("extensions.readError")); }; reader.readAsArrayBuffer(file); // Reset input e.target.value = ""; }, [t], ); const handleUpload = useCallback(async () => { if (!pendingFile || !extensionName.trim()) return; setIsUploading(true); try { await invoke("add_extension", { name: extensionName.trim(), fileName: pendingFile.name, fileData: pendingFile.data, }); showSuccessToast(t("extensions.uploadSuccess")); setShowUploadForm(false); setPendingFile(null); setExtensionName(""); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } finally { setIsUploading(false); } }, [pendingFile, extensionName, loadData, t]); const handleDeleteExtension = useCallback(async () => { if (!extensionToDelete) return; setIsDeleting(true); try { await invoke("delete_extension", { extensionId: extensionToDelete.id }); showSuccessToast(t("extensions.deleteSuccess")); setExtensionToDelete(null); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } finally { setIsDeleting(false); } }, [extensionToDelete, loadData, t]); const handleCreateGroup = useCallback(async () => { if (!newGroupName.trim()) return; try { await invoke("create_extension_group", { name: newGroupName.trim() }); showSuccessToast(t("extensions.groupCreateSuccess")); setShowCreateGroup(false); setNewGroupName(""); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } }, [newGroupName, loadData, t]); const handleSaveGroupEdits = useCallback(async () => { if (!editingGroup || !editGroupName.trim()) return; try { // Update group name await invoke("update_extension_group", { groupId: editingGroup.id, name: editGroupName.trim(), }); // Compute diff of extensions const originalIds = new Set(editingGroup.extension_ids); const newIds = new Set(editGroupExtensionIds); // Add new extensions for (const extId of editGroupExtensionIds) { if (!originalIds.has(extId)) { await invoke("add_extension_to_group", { groupId: editingGroup.id, extensionId: extId, }); } } // Remove removed extensions for (const extId of editingGroup.extension_ids) { if (!newIds.has(extId)) { await invoke("remove_extension_from_group", { groupId: editingGroup.id, extensionId: extId, }); } } showSuccessToast(t("extensions.groupUpdateSuccess")); setEditingGroup(null); setEditGroupName(""); setEditGroupExtensionIds([]); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } }, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]); const handleDeleteGroup = useCallback(async () => { if (!groupToDelete) return; setIsDeleting(true); try { await invoke("delete_extension_group", { groupId: groupToDelete.id }); showSuccessToast(t("extensions.groupDeleteSuccess")); setGroupToDelete(null); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } finally { setIsDeleting(false); } }, [groupToDelete, loadData, t]); const renderCompatIcons = (compat: string[]) => { const hasChromium = compat.includes("chromium"); const hasFirefox = compat.includes("firefox"); if (!hasChromium && !hasFirefox) return null; return (
{hasChromium && ( {t("extensions.compatibility.chromium")} )} {hasFirefox && ( {t("extensions.compatibility.firefox")} )}
); }; const renderExtensionIcon = (ext: Extension, size: "sm" | "md" = "md") => { const sizeClass = size === "sm" ? "w-4 h-4" : "w-5 h-5"; if (extensionIcons[ext.id]) { return ( // biome-ignore lint/performance/noImgElement: base64 data URI icons cannot use next/image ); } return ( ); }; const MAX_VISIBLE_ICONS = 3; return ( <> {t("extensions.title")} {limitedMode && } {t("extensions.description")}
{limitedMode && ( <>
{t("extensions.proRequired")}
)}
{/* Tab selector */}
{/* Notice */}
{t("extensions.managedNotice")}
{activeTab === "extensions" && (
{/* Upload form */} {showUploadForm && pendingFile && (
{t("extensions.selectedFile")}:{" "} {pendingFile.name}
setExtensionName(e.target.value)} placeholder={t("extensions.namePlaceholder")} className="flex-1" /> {isUploading ? t("common.buttons.loading") : t("common.buttons.add")}
)} {/* Extensions list */} {isLoading ? (
{t("common.buttons.loading")}
) : extensions.length === 0 ? (
{t("extensions.empty")}
) : (
{extensions.map((ext) => { const syncDot = getSyncStatusDot( ext, extSyncStatus[ext.id], ); return (

{syncDot.tooltip}

{renderExtensionIcon(ext, "sm")} {ext.name} .{ext.file_type} {renderCompatIcons(ext.browser_compatibility)}
handleToggleExtSync(ext) } disabled={isTogglingExtSync[ext.id]} />

{ext.sync_enabled ? t("extensions.syncDisableTooltip") : t("extensions.syncEnableTooltip")}

{t("extensions.editExtension")} {t("extensions.delete")}
); })}
)}
)} {activeTab === "groups" && (
setShowCreateGroup(true)} className="flex gap-2 items-center" disabled={limitedMode} > {t("extensions.createGroup")}
{/* Create group form */} {showCreateGroup && (
setNewGroupName(e.target.value)} placeholder={t("extensions.groupNamePlaceholder")} className="flex-1" onKeyDown={(e) => { if (e.key === "Enter") void handleCreateGroup(); }} /> {t("common.buttons.create")}
)} {/* Groups list */} {extensionGroups.length === 0 ? (
{t("extensions.noGroups")}
) : (
{extensionGroups.map((group) => { const groupExts = group.extension_ids .map((id) => extensions.find((e) => e.id === id)) .filter(Boolean) as Extension[]; const visibleExts = groupExts.slice( 0, MAX_VISIBLE_ICONS, ); const overflowCount = groupExts.length - MAX_VISIBLE_ICONS; const groupSyncDot = getSyncStatusDot( group, extSyncStatus[group.id], ); return (

{groupSyncDot.tooltip}

{group.name}
{visibleExts.map((ext) => ( {renderExtensionIcon(ext, "sm")} {ext.name} ))} {overflowCount > 0 && ( +{overflowCount}
{groupExts .slice(MAX_VISIBLE_ICONS) .map((ext) => (

{ext.name}

))}
)} {groupExts.length === 0 && ( {t("extensions.noExtensionsInGroup")} )}
handleToggleGroupSync(group) } disabled={isTogglingGroupSync[group.id]} />

{group.sync_enabled ? t("extensions.syncDisableTooltip") : t("extensions.syncEnableTooltip")}

{t("common.buttons.edit")} {t("extensions.deleteGroup")}
); })}
)}
)}
{t("common.buttons.close")}
{/* Group editing dialog */} { if (!open) { setEditingGroup(null); setEditGroupName(""); setEditGroupExtensionIds([]); } }} > {t("extensions.editGroup")} {t("extensions.editGroupDescription")}
setEditGroupName(e.target.value)} placeholder={t("extensions.groupNamePlaceholder")} />
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id)) .length > 0 && (
)}
{editGroupExtensionIds.length === 0 ? (
{t("extensions.noExtensionsInGroup")}
) : (
{editGroupExtensionIds.map((extId) => { const ext = extensions.find((e) => e.id === extId); if (!ext) return null; return (
{renderExtensionIcon(ext, "sm")} {ext.name} {renderCompatIcons(ext.browser_compatibility)}
); })}
)}
{t("common.buttons.save")}
{/* Extension editing dialog */} { if (!open) { setEditingExtension(null); setEditExtensionName(""); setPendingUpdateFile(null); } }} > {t("extensions.editExtension")} {t("extensions.editExtensionDescription")} {editingExtension && (
setEditExtensionName(e.target.value)} placeholder={t("extensions.namePlaceholder")} onKeyDown={(e) => { if (e.key === "Enter") void handleUpdateExtension(); }} />
{/* Metadata from manifest.json */}
{editingExtension.version && ( <> {t("extensions.version")} {editingExtension.version} )} {editingExtension.author && ( <> {t("extensions.author")} {editingExtension.author} )} {editingExtension.description && ( <> {t("common.labels.description")} {editingExtension.description} )} {t("extensions.compatibility.label")}
{renderCompatIcons(editingExtension.browser_compatibility)}
{t("common.labels.type")} .{editingExtension.file_type} {editingExtension.homepage_url && ( <> {t("extensions.homepage")} {editingExtension.homepage_url} )} {!editingExtension.version && !editingExtension.author && !editingExtension.description && !editingExtension.homepage_url && ( {t("extensions.noMetadata")} )}
{/* Re-upload */}
document.getElementById("ext-edit-file-input")?.click() } > {t("extensions.selectFile")} {pendingUpdateFile && ( {pendingUpdateFile.name} )}
)} {t("common.buttons.save")}
{/* Delete extension confirmation */} setExtensionToDelete(null)} onConfirm={handleDeleteExtension} title={t("extensions.deleteConfirmTitle")} description={t("extensions.deleteConfirmDescription", { name: extensionToDelete?.name ?? "", })} isLoading={isDeleting} /> {/* Delete group confirmation */} setGroupToDelete(null)} onConfirm={handleDeleteGroup} title={t("extensions.deleteGroupConfirmTitle")} description={t("extensions.deleteGroupConfirmDescription", { name: groupToDelete?.name ?? "", })} isLoading={isDeleting} /> ); }