/* 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/. */ /** * `FlavorMergeDialog` — UI for the three-way flavor merge. * * The user opens this from the Flavor dialog or by importing an `.iflv` * with the "merge" strategy. We: * * 1. Pick "ours" (defaults to the currently-active flavor) and * "theirs" (the incoming flavor). * 2. Call `mergeFlavors(base, theirs, ours)`. The base is whichever * stored ancestor the import previewed — for the import-merge * path it's the imported flavor's `id` matched in storage (best * effort; otherwise we use `ours` as the base, which means * conflicts surface as "their changes vs current"). * 3. Render each `MergeConflict` with a chooser * (theirs / ours / base) and apply the user's selection to the * merged result before saving. * 4. Save the merged flavor as a new id (`.merge-`) * so neither input is overwritten silently. * * Phase 3 T13. Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §5. */ import { useEffect, useMemo, useState } from 'react'; import { Check, GitMerge, X } from 'lucide-react'; import { flavorMergedId, mergeFlavors, type Flavor, type MergeConflict, type MergeResult, } from '@ifc-lite/extensions'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { toast } from '@/components/ui/toast'; interface FlavorMergeDialogProps { open: boolean; /** The flavor being merged IN. */ theirs: Flavor | null; onClose: () => void; /** Called after a successful merge so the parent can refresh. */ onMerged?: (merged: Flavor) => void; } type ConflictResolution = 'theirs' | 'ours' | 'base'; export function FlavorMergeDialog({ open, theirs, onClose, onMerged }: FlavorMergeDialogProps) { const host = useExtensionHost(); const [ours, setOurs] = useState(null); const [base, setBase] = useState(null); const [busy, setBusy] = useState(false); const [resolutions, setResolutions] = useState>({}); // Resolve "ours" = active flavor, "base" = stored copy of their id if // present (otherwise fall back to ours so the merge degrades to a // two-way merge surfacing every diff as a conflict). useEffect(() => { if (!open || !theirs) return; let cancelled = false; void (async () => { const active = await host.flavors.getActive(); const list = await host.flavors.list(); const stored = list.find((f) => f.id === theirs.id); if (cancelled) return; setOurs(active ?? null); setBase(stored ?? active ?? null); setResolutions({}); })(); return () => { cancelled = true; }; }, [open, theirs, host]); const mergeResult = useMemo(() => { if (!theirs || !ours || !base) return null; return mergeFlavors(base, theirs, ours); }, [theirs, ours, base]); const conflictKey = (c: MergeConflict): string => `${c.kind}:${c.key}`; const handleApply = async () => { if (!mergeResult || !theirs || !base) return; setBusy(true); try { // Deep clone so applyChoice's index mutations on inner arrays // don't reach back into the memoised mergeResult. const merged: Flavor = { ...mergeResult.merged, extensions: [...mergeResult.merged.extensions], lenses: [...mergeResult.merged.lenses], savedQueries: [...mergeResult.merged.savedQueries], keybindings: [...mergeResult.merged.keybindings], settings: { ...mergeResult.merged.settings }, }; // Apply per-conflict resolution: where the user picked theirs, // we already merged in their value via the conflict choice; // where they picked base we have to re-write the merged field. // For v1 we honour the choice for extension version + setting // values; lens/saved_query/keybinding stay on the default // (ours wins) since list-id merge already picked sensibly. for (const conflict of mergeResult.conflicts) { const choice = resolutions[conflictKey(conflict)]; if (!choice || choice === 'ours') continue; applyChoice(merged, conflict, choice, theirs, base); } merged.id = flavorMergedId(theirs.id); merged.updatedAt = new Date().toISOString(); await host.flavors.put(merged, 'three-way merge'); toast.success(`Merged into ${merged.id}`); onMerged?.(merged); onClose(); } catch (err) { toast.error(`Merge failed: ${err instanceof Error ? err.message : String(err)}`); } finally { setBusy(false); } }; if (!theirs) return null; return ( !o && onClose()}> Merge flavor {!ours || !base ? (
No active flavor — switch to a flavor first, then retry the merge.
) : !mergeResult ? (
Computing merge…
) : mergeResult.conflicts.length === 0 ? (
Clean merge — no conflicts between{' '} {theirs.name} and{' '} {ours.name}.
) : (
{mergeResult.conflicts.length} conflict {mergeResult.conflicts.length === 1 ? '' : 's'} between{' '} {theirs.name} (theirs) and{' '} {ours.name} (ours).
    {mergeResult.conflicts.map((conflict) => { const key = conflictKey(conflict); const choice = resolutions[key] ?? 'ours'; const hasBase = conflict.base !== undefined; return (
  • {conflict.kind} {conflict.key}
    setResolutions((r) => ({ ...r, [key]: 'theirs' }))} /> setResolutions((r) => ({ ...r, [key]: 'ours' }))} /> {conflict.base !== undefined && ( setResolutions((r) => ({ ...r, [key]: 'base' }))} /> )}
  • ); })}
)}
); } function ResolutionChip({ label, value, active, onClick, }: { label: string; value: unknown; active: boolean; onClick: () => void; }) { return ( ); } function formatValue(value: unknown): string { if (value === null || value === undefined) return '—'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); try { return JSON.stringify(value); } catch { return '(unserialisable)'; } } /** * Best-effort application of a non-default conflict choice onto a * merged flavor. Covers the kinds where post-merge fixup is * straightforward; complex shapes (lenses, queries, keybindings) * already use list-id resolution at merge time. */ function applyChoice( merged: Flavor, conflict: MergeConflict, choice: 'theirs' | 'base', theirs: Flavor, base: Flavor, ): void { const source = choice === 'theirs' ? theirs : base; switch (conflict.kind) { case 'extension_version': case 'extension_capabilities': { const src = source.extensions.find((e) => e.id === conflict.key); if (!src) return; const idx = merged.extensions.findIndex((e) => e.id === conflict.key); if (idx >= 0) { merged.extensions[idx] = src; } else { merged.extensions = [...merged.extensions, src]; } break; } case 'setting': { merged.settings = { ...merged.settings, [conflict.key]: source.settings[conflict.key] }; break; } case 'lens': { const src = source.lenses.find((l) => l.id === conflict.key); if (!src) return; const idx = merged.lenses.findIndex((l) => l.id === conflict.key); if (idx >= 0) merged.lenses[idx] = src; break; } case 'saved_query': { const src = source.savedQueries.find((q) => q.id === conflict.key); if (!src) return; const idx = merged.savedQueries.findIndex((q) => q.id === conflict.key); if (idx >= 0) merged.savedQueries[idx] = src; break; } case 'keybinding': { const [command, key] = conflict.key.split('@'); const src = source.keybindings.find((k) => k.command === command && k.key === key); if (!src) return; const idx = merged.keybindings.findIndex((k) => k.command === command && k.key === key); if (idx >= 0) merged.keybindings[idx] = src; break; } } }