|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ |
| 4 | + |
| 5 | +/** |
| 6 | + * Save/reopen a portable federation setup (#3930). |
| 7 | + * |
| 8 | + * Mounted once from `useFileCommands` (same pattern as `ShareDialog`), so it |
| 9 | + * is reachable from the command palette regardless of which toolbar style is |
| 10 | + * active. Two hidden `<input type="file">`s drive the two-step "open" flow |
| 11 | + * (pick the saved `.federation.json`, then pick the local model files to |
| 12 | + * match against it); the review step never applies anything silently — every |
| 13 | + * slot is shown as matched, mismatched (same name, different content/size), |
| 14 | + * or missing before the user confirms. |
| 15 | + */ |
| 16 | + |
| 17 | +import { useCallback, useEffect, useRef, useState } from 'react'; |
| 18 | +import { AlertTriangle, Anchor, Check, FileWarning } from 'lucide-react'; |
| 19 | +import { |
| 20 | + Dialog, |
| 21 | + DialogContent, |
| 22 | + DialogDescription, |
| 23 | + DialogFooter, |
| 24 | + DialogHeader, |
| 25 | + DialogTitle, |
| 26 | +} from '@/components/ui/dialog'; |
| 27 | +import { Button } from '@/components/ui/button'; |
| 28 | +import { toast } from '@/components/ui/toast'; |
| 29 | +import { useFederationSetup } from '@/hooks/useFederationSetup'; |
| 30 | +import { |
| 31 | + parseFederationSetupFile, |
| 32 | + type FederationSetupFile, |
| 33 | + type FederationSetupSlotMatch, |
| 34 | +} from '@/lib/federation/federationSetupFile'; |
| 35 | + |
| 36 | +const EVENT_SAVE = 'ifc-lite:save-federation-setup'; |
| 37 | +const EVENT_OPEN = 'ifc-lite:open-federation-setup'; |
| 38 | + |
| 39 | +function confidenceBadge(match: FederationSetupSlotMatch): { label: string; icon: typeof Check; tone: string } { |
| 40 | + switch (match.confidence) { |
| 41 | + case 'content': |
| 42 | + return { label: 'Matched', icon: Check, tone: 'text-emerald-600 dark:text-emerald-400' }; |
| 43 | + case 'name-size': |
| 44 | + return { label: 'Matched (by name)', icon: Check, tone: 'text-emerald-600 dark:text-emerald-400' }; |
| 45 | + case 'name-only': |
| 46 | + return { label: 'Same name, different file', icon: FileWarning, tone: 'text-amber-600 dark:text-amber-400' }; |
| 47 | + case 'none': |
| 48 | + return { label: 'Missing', icon: AlertTriangle, tone: 'text-red-600 dark:text-red-400' }; |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +export function FederationSetupControls() { |
| 53 | + const { exportFederationSetup, matchFederationSetup, applyFederationSetup } = useFederationSetup(); |
| 54 | + |
| 55 | + const setupFileInputRef = useRef<HTMLInputElement>(null); |
| 56 | + const modelFilesInputRef = useRef<HTMLInputElement>(null); |
| 57 | + const [pendingSetup, setPendingSetup] = useState<FederationSetupFile | null>(null); |
| 58 | + const [matches, setMatches] = useState<FederationSetupSlotMatch[] | null>(null); |
| 59 | + const [applying, setApplying] = useState(false); |
| 60 | + |
| 61 | + const handleSave = useCallback(() => { |
| 62 | + void exportFederationSetup().then((result) => { |
| 63 | + if (!result.ok) toast.error(result.error); |
| 64 | + else toast.success('Federation setup saved.'); |
| 65 | + }); |
| 66 | + }, [exportFederationSetup]); |
| 67 | + |
| 68 | + useEffect(() => { |
| 69 | + const onSave = () => handleSave(); |
| 70 | + const onOpen = () => setupFileInputRef.current?.click(); |
| 71 | + window.addEventListener(EVENT_SAVE, onSave); |
| 72 | + window.addEventListener(EVENT_OPEN, onOpen); |
| 73 | + return () => { |
| 74 | + window.removeEventListener(EVENT_SAVE, onSave); |
| 75 | + window.removeEventListener(EVENT_OPEN, onOpen); |
| 76 | + }; |
| 77 | + }, [handleSave]); |
| 78 | + |
| 79 | + const handleSetupFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| 80 | + const file = e.target.files?.[0]; |
| 81 | + e.target.value = ''; |
| 82 | + if (!file) return; |
| 83 | + void file.text().then((text) => { |
| 84 | + const result = parseFederationSetupFile(text); |
| 85 | + if (!result.ok) { |
| 86 | + toast.error(`Not a valid federation setup file: ${result.error}`); |
| 87 | + return; |
| 88 | + } |
| 89 | + setPendingSetup(result.setup); |
| 90 | + toast.info( |
| 91 | + `Loaded a setup with ${result.setup.slots.length} model slot${result.setup.slots.length === 1 ? '' : 's'} — now pick the model file(s) to match against it.`, |
| 92 | + ); |
| 93 | + modelFilesInputRef.current?.click(); |
| 94 | + }); |
| 95 | + }, []); |
| 96 | + |
| 97 | + const handleModelFilesSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| 98 | + const files = Array.from(e.target.files ?? []); |
| 99 | + e.target.value = ''; |
| 100 | + if (!pendingSetup || files.length === 0) return; |
| 101 | + void matchFederationSetup(pendingSetup, files).then(setMatches); |
| 102 | + }, [pendingSetup, matchFederationSetup]); |
| 103 | + |
| 104 | + const closeReview = useCallback(() => { |
| 105 | + setMatches(null); |
| 106 | + setPendingSetup(null); |
| 107 | + }, []); |
| 108 | + |
| 109 | + const handleApply = useCallback(() => { |
| 110 | + if (!matches) return; |
| 111 | + setApplying(true); |
| 112 | + void applyFederationSetup(matches).then((result) => { |
| 113 | + setApplying(false); |
| 114 | + closeReview(); |
| 115 | + if (result.outcome === 'failed') { |
| 116 | + toast.error('Could not restore the federation setup — none of the saved models were found.'); |
| 117 | + return; |
| 118 | + } |
| 119 | + const parts: string[] = [`Restored ${result.restoredCount}/${result.totalSlots} model(s)`]; |
| 120 | + if (result.missingSlots.length > 0) parts.push(`missing: ${result.missingSlots.join(', ')}`); |
| 121 | + if (result.mismatchedSlots.length > 0) parts.push(`same name, different content: ${result.mismatchedSlots.join(', ')}`); |
| 122 | + if (result.anchorMissing) parts.push('alignment anchor could not be restored'); |
| 123 | + const message = parts.join(' — '); |
| 124 | + if (result.outcome === 'restored' && !result.anchorMissing) toast.success(message); |
| 125 | + else toast.error(message); |
| 126 | + }); |
| 127 | + }, [matches, applyFederationSetup, closeReview]); |
| 128 | + |
| 129 | + return ( |
| 130 | + <> |
| 131 | + <input |
| 132 | + ref={setupFileInputRef} |
| 133 | + type="file" |
| 134 | + accept=".json,application/json" |
| 135 | + className="hidden" |
| 136 | + onChange={handleSetupFileSelected} |
| 137 | + /> |
| 138 | + <input |
| 139 | + ref={modelFilesInputRef} |
| 140 | + type="file" |
| 141 | + multiple |
| 142 | + className="hidden" |
| 143 | + onChange={handleModelFilesSelected} |
| 144 | + /> |
| 145 | + <Dialog open={matches !== null} onOpenChange={(open) => { if (!open) closeReview(); }}> |
| 146 | + <DialogContent> |
| 147 | + <DialogHeader> |
| 148 | + <DialogTitle>Reopen federation setup</DialogTitle> |
| 149 | + <DialogDescription> |
| 150 | + Review how each saved model slot matched your local files before restoring. |
| 151 | + </DialogDescription> |
| 152 | + </DialogHeader> |
| 153 | + <div className="max-h-80 overflow-y-auto space-y-1"> |
| 154 | + {matches?.map((match) => { |
| 155 | + const badge = confidenceBadge(match); |
| 156 | + const Icon = badge.icon; |
| 157 | + return ( |
| 158 | + <div |
| 159 | + key={match.slotIndex} |
| 160 | + className="flex items-center gap-2 px-2 py-1.5 border border-zinc-200 dark:border-zinc-800 rounded text-sm" |
| 161 | + > |
| 162 | + {match.slot.anchor && <Anchor className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />} |
| 163 | + <span className="truncate flex-1">{match.slot.name}</span> |
| 164 | + <span className={`inline-flex items-center gap-1 text-xs ${badge.tone}`}> |
| 165 | + <Icon className="h-3.5 w-3.5" /> |
| 166 | + {badge.label} |
| 167 | + </span> |
| 168 | + </div> |
| 169 | + ); |
| 170 | + })} |
| 171 | + </div> |
| 172 | + <DialogFooter> |
| 173 | + <Button variant="outline" onClick={closeReview} disabled={applying}>Cancel</Button> |
| 174 | + <Button |
| 175 | + onClick={handleApply} |
| 176 | + disabled={applying || !matches?.some((m) => m.file !== null)} |
| 177 | + > |
| 178 | + {applying ? 'Restoring…' : 'Restore federation'} |
| 179 | + </Button> |
| 180 | + </DialogFooter> |
| 181 | + </DialogContent> |
| 182 | + </Dialog> |
| 183 | + </> |
| 184 | + ); |
| 185 | +} |
| 186 | + |
| 187 | +export default FederationSetupControls; |
0 commit comments