/* 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/. */ /** * `FlavorListView` — CRUD surface for the flavor library. * * Sits inside `FlavorDialog`; the dialog owns data fetching, busy * state, and outgoing actions. Each row supports the full management * loop a user actually needs: * * - **Activate** (non-active rows) * - **Capture into THIS flavor** — snapshot the live viewer state * into that specific flavor, not just the active one * - **Rename** (inline click-to-edit on the name) * - **Duplicate** — clone the flavor with a fresh id * - **Export** / **Delete** * * Header offers **New flavor** (empty, name it) and **Save current as * flavor** (snapshot from current viewer state, name it). Both flows * open an inline name input so the user never sees an empty list with * no path forward. */ import { useState } from 'react'; import { Camera, Copy, Download, FilePlus, Pencil, RefreshCcw, Upload, X, Check } from 'lucide-react'; import type { Flavor } from '@ifc-lite/extensions'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; /** Number of clash rules (customs + modified built-ins) stored in a flavor's * `settings.clash` blob. 0 when the flavor carries no clash config. */ function clashRuleCount(flavor: Flavor): number { const clash = (flavor.settings as Record | undefined)?.clash; if (!clash || typeof clash !== 'object') return 0; const presets = (clash as { presets?: unknown }).presets; return Array.isArray(presets) ? presets.length : 0; } interface FlavorListViewProps { flavors: readonly Flavor[]; activeId: string | undefined; busy: boolean; /** Count of lenses currently in viewer state — surfaces "you have N lenses uncaptured" hint. */ liveLensCount: number; onActivate(id: string): void; onExport(id: string): void; onDelete(id: string): void; onImportClick(): void; onReset(): void; /** Snapshot current viewer state into a SPECIFIC flavor (not just active). */ onCaptureInto(id: string): void; /** Rename a flavor. Caller validates. */ onRename(id: string, name: string): void; /** Duplicate a flavor with a fresh id. */ onDuplicate(id: string): void; /** Create a new flavor — empty body, user-provided name. Optional snapshot. */ onCreate(opts: { name: string; snapshot: boolean }): void; } type Creating = null | { mode: 'empty' | 'snapshot'; name: string }; export function FlavorListView({ flavors, activeId, busy, liveLensCount, onActivate, onExport, onDelete, onImportClick, onReset, onCaptureInto, onRename, onDuplicate, onCreate, }: FlavorListViewProps) { const [creating, setCreating] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const startRename = (flavor: Flavor) => { setRenamingId(flavor.id); setRenameValue(flavor.name); }; const commitRename = () => { if (renamingId && renameValue.trim().length > 0) { onRename(renamingId, renameValue.trim()); } setRenamingId(null); }; const cancelRename = () => setRenamingId(null); const submitCreate = () => { if (!creating || creating.name.trim().length === 0) return; onCreate({ name: creating.name.trim(), snapshot: creating.mode === 'snapshot' }); setCreating(null); }; return (
Flavors bundle your extensions, lenses, queries, clash rules, and layout. Switch to isolate experiments; export to share or back up.
{/* Inline name input — appears when user clicks "New flavor" or "Save current as flavor". Keeping it inline avoids a nested modal stack inside the Flavors dialog. */} {creating && (
setCreating({ ...creating, name: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') submitCreate(); if (e.key === 'Escape') setCreating(null); }} placeholder={creating.mode === 'snapshot' ? 'Cost estimating' : 'Empty workspace'} className="h-8 text-xs" disabled={busy} /> {/* Snapshot/empty toggle so the user can switch mode without re-opening the form. */}
)} {flavors.length === 0 ? (
No flavors yet. Click New flavor above, Reset for the baseline, or Import a .iflv.
) : (
    {flavors.map((flavor) => { const isActive = flavor.id === activeId; const isRenaming = renamingId === flavor.id; const hasUncaptured = isActive && liveLensCount > flavor.lenses.length; return (
  • {isRenaming ? ( <> setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') cancelRename(); }} className="h-7 text-sm" /> ) : ( <> {isActive && ( Active )} {hasUncaptured && ( {liveLensCount - flavor.lenses.length} uncaptured )} )}
    {flavor.id}
    {flavor.description && (
    {flavor.description}
    )}
    {flavor.extensions.length} ext · {flavor.lenses.length} lens ·{' '} {flavor.savedQueries.length} qry · {clashRuleCount(flavor)} clash · updated{' '} {new Date(flavor.updatedAt).toLocaleDateString()}
    {!isActive && ( )} {!isActive && ( )}
  • ); })}
)}
); }