/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `FlavorDialog` — manage flavors: list, switch, export, import, reset. * * The export side serialises the active (or selected) flavor to an * `.iflv` file via `FlavorService.exportFlavor`. The import side * accepts an `.iflv`, previews + validates it, and offers replace / * save-as-new strategies. Strategy choice is explicit so users don't * silently overwrite a flavor they've been iterating on. * * Phase 3 scope. The merge UI (T13) lives in a separate component. * * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §6. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Palette } from 'lucide-react'; import type { Flavor, UnpackedFlavor } from '@ifc-lite/extensions'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { toast } from '@/components/ui/toast'; import { FlavorMergeDialog } from './FlavorMergeDialog'; import { FlavorListView } from './FlavorListView'; import { FlavorImportPreview } from './FlavorImportPreview'; import * as toastText from './toast-helpers'; import { HelpHint } from './HelpHint'; import { useViewerStore } from '@/store'; import { serializeClashConfig } from '@/lib/clash/persistence'; /** Snapshot the current clash rule-set + detection settings for a flavor's * `settings.clash` blob, so each profile carries its own clash config. */ function captureClashConfig(): unknown { const s = useViewerStore.getState(); return serializeClashConfig(s.clashPresets, { mode: s.clashMode, tolerance: s.clashTolerance, clearance: s.clashClearance, clusterEpsilon: s.clashClusterEpsilon, reportTouch: s.clashReportTouch, groupBy: s.clashGroupBy, }); } interface FlavorDialogProps { open: boolean; onClose: () => void; } export function FlavorDialog({ open, onClose }: FlavorDialogProps) { const host = useExtensionHost(); const [flavors, setFlavors] = useState([]); const [activeId, setActiveId] = useState(); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState<{ bytes: Uint8Array; unpacked: UnpackedFlavor } | null>(null); const [mergeTarget, setMergeTarget] = useState(null); /** Live lens count from the viewer store — drives the "N new lenses * not yet in active flavor" banner. */ const liveLensCount = useViewerStore((s) => s.savedLenses.length); const fileInputRef = useRef(null); const refresh = useCallback(async () => { const [list, active] = await Promise.all([ host.flavors.list(), host.flavors.getActive(), ]); setFlavors(list); setActiveId(active?.id); }, [host]); useEffect(() => { if (!open) return; void refresh(); return host.flavors.onChange(() => { void refresh(); }); }, [open, host, refresh]); // When the dialog closes (or the preview is dismissed), zero the // preview bytes so a sensitive `.iflv` doesn't sit in memory longer // than necessary. Best effort — the GC will reclaim eventually. useEffect(() => { if (open) return; if (preview) { preview.bytes.fill(0); setPreview(null); } if (mergeTarget) setMergeTarget(null); }, [open, preview, mergeTarget]); const handleExport = async (id: string) => { setBusy(true); try { const bytes = await host.flavors.exportFlavor(id); // Copy into a fresh ArrayBuffer so DOM Blob typings accept it — // Uint8Array isn't a BlobPart in strict // TS lib.dom, and `.slice()` on the underlying buffer may // return SharedArrayBuffer. const buffer = new ArrayBuffer(bytes.byteLength); new Uint8Array(buffer).set(bytes); const blob = new Blob([buffer], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${id || 'flavor'}.iflv`; a.click(); URL.revokeObjectURL(url); toast.success(toastText.flavorExported(`${id}.iflv`)); } catch (err) { toast.error(toastText.failed('Export', err)); } finally { setBusy(false); } }; const handleActivate = async (id: string) => { setBusy(true); try { // Drive the full switcher: enable/disable extensions to match // the target flavor, then move the active pointer. Falls back // to the bare pointer set on failure so the user can still // recover. await host.switchFlavor(id); toast.success(toastText.flavorSwitched(id)); } catch (err) { toast.error(toastText.failed('Activate', err)); } finally { setBusy(false); } }; const handleDelete = async (id: string) => { if (!confirm(`Delete flavor ${id}?`)) return; setBusy(true); try { await host.flavors.delete(id); toast.success(toastText.flavorDeleted(id)); } catch (err) { toast.error(toastText.failed('Delete', err)); } finally { setBusy(false); } }; /** * Snapshot the current viewer state into a SPECIFIC flavor (not * just the active one). Powers the per-row Capture button, so a * user can keep two flavors side-by-side and update each from the * same viewer session without switching first. * * v1 scope: saved lenses. The flavor schema also reserves slots * for savedQueries / keybindings / layout / settings — those land * as the viewer surfaces them in stores we can read deterministically. */ const handleCaptureInto = async (flavorId: string) => { setBusy(true); try { const target = await host.flavors.list().then((list) => list.find((f) => f.id === flavorId)); if (!target) { toast.error(`Flavor "${flavorId}" not found.`); return; } const savedLenses = useViewerStore.getState().savedLenses; const lenses = savedLenses.map((lens) => ({ id: lens.id, name: lens.name ?? lens.id, definition: lens as unknown as Parameters[0]['lenses'][number]['definition'], })); const next = { ...target, lenses, settings: { ...target.settings, clash: captureClashConfig() } as typeof target.settings, // Capture the workspace-sidebar layout (#1208) into the reserved opaque // layout slot so it travels with the flavor (order / visible set / mode / width). layout: { // Preserve any other layout fields an imported / future flavor carries; // only the sidebar entry of `state` is being (re)captured here (#1208). ...target.layout, state: { ...target.layout?.state, sidebar: useViewerStore.getState().serializeSidebarLayout() as unknown as (typeof target.layout)['state'][string], }, }, updatedAt: new Date().toISOString(), }; await host.flavors.put(next, 'capture current state'); toast.success(`Captured ${lenses.length} lens${lenses.length === 1 ? '' : 'es'} + clash rules + sidebar layout into ${target.name}`); } catch (err) { toast.error(toastText.failed('Capture', err)); } finally { setBusy(false); } }; /** * Create a brand-new flavor. `snapshot=true` seeds it with the * current viewer lenses; otherwise it starts empty. The new flavor * is activated so the user can immediately start working in it. */ const handleCreate = async (opts: { name: string; snapshot: boolean }) => { setBusy(true); try { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); // Slugify the name for a stable id; fall back to a timestamp. const slug = opts.name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); const id = `local.${slug || 'flavor'}.${stamp}`; const now = new Date().toISOString(); const lenses = opts.snapshot ? useViewerStore.getState().savedLenses.map((lens) => ({ id: lens.id, name: lens.name ?? lens.id, definition: lens as unknown as Parameters[0]['lenses'][number]['definition'], })) : []; const flavor: Flavor = { schemaVersion: 1, id, name: opts.name, description: opts.snapshot ? 'Captured from current viewer state.' : 'New empty flavor.', createdAt: now, updatedAt: now, extensions: [], lenses, savedQueries: [], keybindings: [], layout: { state: opts.snapshot ? { sidebar: useViewerStore.getState().serializeSidebarLayout() } : {}, } as unknown as Flavor['layout'], settings: (opts.snapshot ? { clash: captureClashConfig() } : {}) as Flavor['settings'], }; await host.flavors.put(flavor, opts.snapshot ? 'created from current state' : 'created empty'); await host.flavors.activate(id); toast.success(`Created "${opts.name}"${opts.snapshot ? ` with ${lenses.length} lens${lenses.length === 1 ? '' : 'es'}` : ''}.`); } catch (err) { toast.error(toastText.failed('Create', err)); } finally { setBusy(false); } }; /** Rename a flavor in place. Keeps the id stable — only `name` changes. */ const handleRename = async (id: string, name: string) => { setBusy(true); try { const target = await host.flavors.list().then((list) => list.find((f) => f.id === id)); if (!target) { toast.error(`Flavor "${id}" not found.`); return; } if (target.name === name) return; await host.flavors.put({ ...target, name, updatedAt: new Date().toISOString() }, `renamed to "${name}"`); toast.success(`Renamed to "${name}".`); } catch (err) { toast.error(toastText.failed('Rename', err)); } finally { setBusy(false); } }; /** Duplicate a flavor with a fresh id and "(copy)" suffix. */ const handleDuplicate = async (id: string) => { setBusy(true); try { const target = await host.flavors.list().then((list) => list.find((f) => f.id === id)); if (!target) { toast.error(`Flavor "${id}" not found.`); return; } const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const newId = `${target.id}.copy.${stamp}`; const now = new Date().toISOString(); const clone: Flavor = { ...target, id: newId, name: `${target.name} (copy)`, createdAt: now, updatedAt: now, }; await host.flavors.put(clone, `duplicated from ${target.id}`); toast.success(`Duplicated as "${clone.name}".`); } catch (err) { toast.error(toastText.failed('Duplicate', err)); } finally { setBusy(false); } }; const handleReset = async () => { if (!confirm('Reset to baseline flavor? Other flavors are preserved.')) return; setBusy(true); try { await host.flavors.resetToDefaults(); toast.success(toastText.flavorReset()); } catch (err) { toast.error(toastText.failed('Reset', err)); } finally { setBusy(false); } }; const handleFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; if (!file.name.toLowerCase().endsWith('.iflv')) { toast.error(`Expected a .iflv flavor file, got ${file.name}.`); return; } try { const bytes = new Uint8Array(await file.arrayBuffer()); const unpacked = await host.flavors.preview(bytes); setPreview({ bytes, unpacked }); } catch (err) { toast.error(`Preview failed: ${err instanceof Error ? err.message : String(err)}`); } }; const handleConfirmImport = async (strategy: 'replace' | 'save-as-new') => { if (!preview) return; setBusy(true); try { const flavor = await host.flavors.importFlavor(preview.unpacked, { strategy }); toast.success(toastText.flavorImported(flavor.name)); setPreview(null); } catch (err) { if (err && (err as { name?: string }).name === 'ExtensionStorageQuotaError') { toast.error( 'Out of browser storage — delete a flavor or extension and try again.', ); } else { toast.error(toastText.failed('Import', err)); } } finally { setBusy(false); } }; return ( !o && onClose()}> Flavors

A flavor bundles your installed extensions, lenses, saved queries, layout, settings, and prompt overlay into a switchable profile.

New flavor / Save current as flavor creates one (empty or snapshotted from your current viewer state).

Per-row: Activate switches to it (lenses restore). Camera captures the current viewer state into THAT flavor (not just the active one). Click the name to rename.{' '} Copy duplicates,{' '} Download exports a .iflv.

Import previews a .iflv{' '} then offers replace / save-as-new / three-way merge.{' '} Reset restores the empty baseline.

{preview ? ( setPreview(null)} onMerge={() => { setMergeTarget(preview.unpacked.flavor); setPreview(null); }} onSaveAsNew={() => void handleConfirmImport('save-as-new')} onReplace={() => void handleConfirmImport('replace')} /> ) : ( <> void handleActivate(id)} onExport={(id) => void handleExport(id)} onDelete={(id) => void handleDelete(id)} onImportClick={() => fileInputRef.current?.click()} onReset={() => void handleReset()} onCaptureInto={(id) => void handleCaptureInto(id)} onRename={(id, name) => void handleRename(id, name)} onDuplicate={(id) => void handleDuplicate(id)} onCreate={(opts) => void handleCreate(opts)} /> { void handleFiles(e.target.files); e.target.value = ''; }} /> )} setMergeTarget(null)} onMerged={() => void refresh()} />
); }