@ifc-lite/viewer 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,398 @@
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
+ * `FlavorDialog` — manage flavors: list, switch, export, import, reset.
7
+ *
8
+ * The export side serialises the active (or selected) flavor to an
9
+ * `.iflv` file via `FlavorService.exportFlavor`. The import side
10
+ * accepts an `.iflv`, previews + validates it, and offers replace /
11
+ * save-as-new strategies. Strategy choice is explicit so users don't
12
+ * silently overwrite a flavor they've been iterating on.
13
+ *
14
+ * Phase 3 scope. The merge UI (T13) lives in a separate component.
15
+ *
16
+ * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §6.
17
+ */
18
+
19
+ import { useCallback, useEffect, useRef, useState } from 'react';
20
+ import { Palette } from 'lucide-react';
21
+ import type { Flavor, UnpackedFlavor } from '@ifc-lite/extensions';
22
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
23
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
24
+ import { toast } from '@/components/ui/toast';
25
+ import { FlavorMergeDialog } from './FlavorMergeDialog';
26
+ import { FlavorListView } from './FlavorListView';
27
+ import { FlavorImportPreview } from './FlavorImportPreview';
28
+ import * as toastText from './toast-helpers';
29
+ import { HelpHint } from './HelpHint';
30
+ import { useViewerStore } from '@/store';
31
+
32
+ interface FlavorDialogProps {
33
+ open: boolean;
34
+ onClose: () => void;
35
+ }
36
+
37
+ export function FlavorDialog({ open, onClose }: FlavorDialogProps) {
38
+ const host = useExtensionHost();
39
+ const [flavors, setFlavors] = useState<Flavor[]>([]);
40
+ const [activeId, setActiveId] = useState<string | undefined>();
41
+ const [busy, setBusy] = useState(false);
42
+ const [preview, setPreview] = useState<{ bytes: Uint8Array; unpacked: UnpackedFlavor } | null>(null);
43
+ const [mergeTarget, setMergeTarget] = useState<Flavor | null>(null);
44
+ /** Live lens count from the viewer store — drives the "N new lenses
45
+ * not yet in active flavor" banner. */
46
+ const liveLensCount = useViewerStore((s) => s.savedLenses.length);
47
+ const fileInputRef = useRef<HTMLInputElement>(null);
48
+
49
+ const refresh = useCallback(async () => {
50
+ const [list, active] = await Promise.all([
51
+ host.flavors.list(),
52
+ host.flavors.getActive(),
53
+ ]);
54
+ setFlavors(list);
55
+ setActiveId(active?.id);
56
+ }, [host]);
57
+
58
+ useEffect(() => {
59
+ if (!open) return;
60
+ void refresh();
61
+ return host.flavors.onChange(() => {
62
+ void refresh();
63
+ });
64
+ }, [open, host, refresh]);
65
+
66
+ // When the dialog closes (or the preview is dismissed), zero the
67
+ // preview bytes so a sensitive `.iflv` doesn't sit in memory longer
68
+ // than necessary. Best effort — the GC will reclaim eventually.
69
+ useEffect(() => {
70
+ if (open) return;
71
+ if (preview) {
72
+ preview.bytes.fill(0);
73
+ setPreview(null);
74
+ }
75
+ if (mergeTarget) setMergeTarget(null);
76
+ }, [open, preview, mergeTarget]);
77
+
78
+ const handleExport = async (id: string) => {
79
+ setBusy(true);
80
+ try {
81
+ const bytes = await host.flavors.exportFlavor(id);
82
+ // Copy into a fresh ArrayBuffer so DOM Blob typings accept it —
83
+ // Uint8Array<ArrayBufferLike> isn't a BlobPart in strict
84
+ // TS lib.dom, and `.slice()` on the underlying buffer may
85
+ // return SharedArrayBuffer.
86
+ const buffer = new ArrayBuffer(bytes.byteLength);
87
+ new Uint8Array(buffer).set(bytes);
88
+ const blob = new Blob([buffer], { type: 'application/octet-stream' });
89
+ const url = URL.createObjectURL(blob);
90
+ const a = document.createElement('a');
91
+ a.href = url;
92
+ a.download = `${id || 'flavor'}.iflv`;
93
+ a.click();
94
+ URL.revokeObjectURL(url);
95
+ toast.success(toastText.flavorExported(`${id}.iflv`));
96
+ } catch (err) {
97
+ toast.error(toastText.failed('Export', err));
98
+ } finally {
99
+ setBusy(false);
100
+ }
101
+ };
102
+
103
+ const handleActivate = async (id: string) => {
104
+ setBusy(true);
105
+ try {
106
+ // Drive the full switcher: enable/disable extensions to match
107
+ // the target flavor, then move the active pointer. Falls back
108
+ // to the bare pointer set on failure so the user can still
109
+ // recover.
110
+ await host.switchFlavor(id);
111
+ toast.success(toastText.flavorSwitched(id));
112
+ } catch (err) {
113
+ toast.error(toastText.failed('Activate', err));
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ };
118
+
119
+ const handleDelete = async (id: string) => {
120
+ if (!confirm(`Delete flavor ${id}?`)) return;
121
+ setBusy(true);
122
+ try {
123
+ await host.flavors.delete(id);
124
+ toast.success(toastText.flavorDeleted(id));
125
+ } catch (err) {
126
+ toast.error(toastText.failed('Delete', err));
127
+ } finally {
128
+ setBusy(false);
129
+ }
130
+ };
131
+
132
+ /**
133
+ * Snapshot the current viewer state into a SPECIFIC flavor (not
134
+ * just the active one). Powers the per-row Capture button, so a
135
+ * user can keep two flavors side-by-side and update each from the
136
+ * same viewer session without switching first.
137
+ *
138
+ * v1 scope: saved lenses. The flavor schema also reserves slots
139
+ * for savedQueries / keybindings / layout / settings — those land
140
+ * as the viewer surfaces them in stores we can read deterministically.
141
+ */
142
+ const handleCaptureInto = async (flavorId: string) => {
143
+ setBusy(true);
144
+ try {
145
+ const target = await host.flavors.list().then((list) => list.find((f) => f.id === flavorId));
146
+ if (!target) {
147
+ toast.error(`Flavor "${flavorId}" not found.`);
148
+ return;
149
+ }
150
+ const savedLenses = useViewerStore.getState().savedLenses;
151
+ const lenses = savedLenses.map((lens) => ({
152
+ id: lens.id,
153
+ name: lens.name ?? lens.id,
154
+ definition: lens as unknown as Parameters<typeof host.flavors.put>[0]['lenses'][number]['definition'],
155
+ }));
156
+ const next = {
157
+ ...target,
158
+ lenses,
159
+ updatedAt: new Date().toISOString(),
160
+ };
161
+ await host.flavors.put(next, 'capture current state');
162
+ toast.success(`Captured ${lenses.length} lens${lenses.length === 1 ? '' : 'es'} into ${target.name}`);
163
+ } catch (err) {
164
+ toast.error(toastText.failed('Capture', err));
165
+ } finally {
166
+ setBusy(false);
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Create a brand-new flavor. `snapshot=true` seeds it with the
172
+ * current viewer lenses; otherwise it starts empty. The new flavor
173
+ * is activated so the user can immediately start working in it.
174
+ */
175
+ const handleCreate = async (opts: { name: string; snapshot: boolean }) => {
176
+ setBusy(true);
177
+ try {
178
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
179
+ // Slugify the name for a stable id; fall back to a timestamp.
180
+ const slug = opts.name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
181
+ const id = `local.${slug || 'flavor'}.${stamp}`;
182
+ const now = new Date().toISOString();
183
+ const lenses = opts.snapshot
184
+ ? useViewerStore.getState().savedLenses.map((lens) => ({
185
+ id: lens.id,
186
+ name: lens.name ?? lens.id,
187
+ definition: lens as unknown as Parameters<typeof host.flavors.put>[0]['lenses'][number]['definition'],
188
+ }))
189
+ : [];
190
+ const flavor: Flavor = {
191
+ schemaVersion: 1,
192
+ id,
193
+ name: opts.name,
194
+ description: opts.snapshot
195
+ ? 'Captured from current viewer state.'
196
+ : 'New empty flavor.',
197
+ createdAt: now,
198
+ updatedAt: now,
199
+ extensions: [],
200
+ lenses,
201
+ savedQueries: [],
202
+ keybindings: [],
203
+ layout: { state: {} },
204
+ settings: {},
205
+ };
206
+ await host.flavors.put(flavor, opts.snapshot ? 'created from current state' : 'created empty');
207
+ await host.flavors.activate(id);
208
+ toast.success(`Created "${opts.name}"${opts.snapshot ? ` with ${lenses.length} lens${lenses.length === 1 ? '' : 'es'}` : ''}.`);
209
+ } catch (err) {
210
+ toast.error(toastText.failed('Create', err));
211
+ } finally {
212
+ setBusy(false);
213
+ }
214
+ };
215
+
216
+ /** Rename a flavor in place. Keeps the id stable — only `name` changes. */
217
+ const handleRename = async (id: string, name: string) => {
218
+ setBusy(true);
219
+ try {
220
+ const target = await host.flavors.list().then((list) => list.find((f) => f.id === id));
221
+ if (!target) {
222
+ toast.error(`Flavor "${id}" not found.`);
223
+ return;
224
+ }
225
+ if (target.name === name) return;
226
+ await host.flavors.put({ ...target, name, updatedAt: new Date().toISOString() }, `renamed to "${name}"`);
227
+ toast.success(`Renamed to "${name}".`);
228
+ } catch (err) {
229
+ toast.error(toastText.failed('Rename', err));
230
+ } finally {
231
+ setBusy(false);
232
+ }
233
+ };
234
+
235
+ /** Duplicate a flavor with a fresh id and "(copy)" suffix. */
236
+ const handleDuplicate = async (id: string) => {
237
+ setBusy(true);
238
+ try {
239
+ const target = await host.flavors.list().then((list) => list.find((f) => f.id === id));
240
+ if (!target) {
241
+ toast.error(`Flavor "${id}" not found.`);
242
+ return;
243
+ }
244
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
245
+ const newId = `${target.id}.copy.${stamp}`;
246
+ const now = new Date().toISOString();
247
+ const clone: Flavor = {
248
+ ...target,
249
+ id: newId,
250
+ name: `${target.name} (copy)`,
251
+ createdAt: now,
252
+ updatedAt: now,
253
+ };
254
+ await host.flavors.put(clone, `duplicated from ${target.id}`);
255
+ toast.success(`Duplicated as "${clone.name}".`);
256
+ } catch (err) {
257
+ toast.error(toastText.failed('Duplicate', err));
258
+ } finally {
259
+ setBusy(false);
260
+ }
261
+ };
262
+
263
+ const handleReset = async () => {
264
+ if (!confirm('Reset to baseline flavor? Other flavors are preserved.')) return;
265
+ setBusy(true);
266
+ try {
267
+ await host.flavors.resetToDefaults();
268
+ toast.success(toastText.flavorReset());
269
+ } catch (err) {
270
+ toast.error(toastText.failed('Reset', err));
271
+ } finally {
272
+ setBusy(false);
273
+ }
274
+ };
275
+
276
+ const handleFiles = async (files: FileList | null) => {
277
+ if (!files || files.length === 0) return;
278
+ const file = files[0];
279
+ if (!file.name.toLowerCase().endsWith('.iflv')) {
280
+ toast.error(`Expected a .iflv flavor file, got ${file.name}.`);
281
+ return;
282
+ }
283
+ try {
284
+ const bytes = new Uint8Array(await file.arrayBuffer());
285
+ const unpacked = await host.flavors.preview(bytes);
286
+ setPreview({ bytes, unpacked });
287
+ } catch (err) {
288
+ toast.error(`Preview failed: ${err instanceof Error ? err.message : String(err)}`);
289
+ }
290
+ };
291
+
292
+ const handleConfirmImport = async (strategy: 'replace' | 'save-as-new') => {
293
+ if (!preview) return;
294
+ setBusy(true);
295
+ try {
296
+ const flavor = await host.flavors.importFlavor(preview.unpacked, { strategy });
297
+ toast.success(toastText.flavorImported(flavor.name));
298
+ setPreview(null);
299
+ } catch (err) {
300
+ if (err && (err as { name?: string }).name === 'ExtensionStorageQuotaError') {
301
+ toast.error(
302
+ 'Out of browser storage — delete a flavor or extension and try again.',
303
+ );
304
+ } else {
305
+ toast.error(toastText.failed('Import', err));
306
+ }
307
+ } finally {
308
+ setBusy(false);
309
+ }
310
+ };
311
+
312
+ return (
313
+ <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
314
+ <DialogContent className="max-w-2xl">
315
+ <DialogHeader>
316
+ <DialogTitle className="flex items-center gap-2">
317
+ <Palette className="h-4 w-4" />
318
+ Flavors
319
+ <HelpHint label="Flavors" side="bottom-start">
320
+ <p>
321
+ A <strong>flavor</strong> bundles your installed
322
+ extensions, lenses, saved queries, layout, settings,
323
+ and prompt overlay into a switchable profile.
324
+ </p>
325
+ <p>
326
+ <strong>New flavor</strong> / <strong>Save current as
327
+ flavor</strong> creates one (empty or snapshotted from
328
+ your current viewer state).
329
+ </p>
330
+ <p>
331
+ Per-row: <strong>Activate</strong> switches to it
332
+ (lenses restore). <strong>Camera</strong> captures the
333
+ current viewer state into THAT flavor (not just the
334
+ active one). Click the name to rename.{' '}
335
+ <strong>Copy</strong> duplicates,{' '}
336
+ <strong>Download</strong> exports a <code>.iflv</code>.
337
+ </p>
338
+ <p>
339
+ <strong>Import</strong> previews a <code>.iflv</code>{' '}
340
+ then offers replace / save-as-new / three-way merge.{' '}
341
+ <strong>Reset</strong> restores the empty baseline.
342
+ </p>
343
+ </HelpHint>
344
+ </DialogTitle>
345
+ </DialogHeader>
346
+
347
+ {preview ? (
348
+ <FlavorImportPreview
349
+ unpacked={preview.unpacked}
350
+ busy={busy}
351
+ onCancel={() => setPreview(null)}
352
+ onMerge={() => {
353
+ setMergeTarget(preview.unpacked.flavor);
354
+ setPreview(null);
355
+ }}
356
+ onSaveAsNew={() => void handleConfirmImport('save-as-new')}
357
+ onReplace={() => void handleConfirmImport('replace')}
358
+ />
359
+ ) : (
360
+ <>
361
+ <FlavorListView
362
+ flavors={flavors}
363
+ activeId={activeId}
364
+ busy={busy}
365
+ liveLensCount={liveLensCount}
366
+ onActivate={(id) => void handleActivate(id)}
367
+ onExport={(id) => void handleExport(id)}
368
+ onDelete={(id) => void handleDelete(id)}
369
+ onImportClick={() => fileInputRef.current?.click()}
370
+ onReset={() => void handleReset()}
371
+ onCaptureInto={(id) => void handleCaptureInto(id)}
372
+ onRename={(id, name) => void handleRename(id, name)}
373
+ onDuplicate={(id) => void handleDuplicate(id)}
374
+ onCreate={(opts) => void handleCreate(opts)}
375
+ />
376
+ <input
377
+ ref={fileInputRef}
378
+ type="file"
379
+ accept=".iflv"
380
+ className="hidden"
381
+ onChange={(e) => {
382
+ void handleFiles(e.target.files);
383
+ e.target.value = '';
384
+ }}
385
+ />
386
+ </>
387
+ )}
388
+
389
+ <FlavorMergeDialog
390
+ open={!!mergeTarget}
391
+ theirs={mergeTarget}
392
+ onClose={() => setMergeTarget(null)}
393
+ onMerged={() => void refresh()}
394
+ />
395
+ </DialogContent>
396
+ </Dialog>
397
+ );
398
+ }
@@ -0,0 +1,79 @@
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
+ * `FlavorImportPreview` — render an unpacked .iflv preview and offer
7
+ * the three import strategies (merge / save as new / replace).
8
+ *
9
+ * Sits inside `FlavorDialog` when a preview is pending. Pure
10
+ * presentational component; the dialog owns the busy state and the
11
+ * outgoing actions.
12
+ */
13
+
14
+ import { FilePlus, GitMerge } from 'lucide-react';
15
+ import type { UnpackedFlavor } from '@ifc-lite/extensions';
16
+ import { Button } from '@/components/ui/button';
17
+
18
+ interface FlavorImportPreviewProps {
19
+ unpacked: UnpackedFlavor;
20
+ busy: boolean;
21
+ onCancel(): void;
22
+ onMerge(): void;
23
+ onSaveAsNew(): void;
24
+ onReplace(): void;
25
+ }
26
+
27
+ export function FlavorImportPreview({
28
+ unpacked,
29
+ busy,
30
+ onCancel,
31
+ onMerge,
32
+ onSaveAsNew,
33
+ onReplace,
34
+ }: FlavorImportPreviewProps) {
35
+ return (
36
+ <div className="space-y-3">
37
+ <div className="text-sm font-medium">Import preview</div>
38
+ <div className="rounded border bg-muted/30 p-3 text-xs space-y-1">
39
+ <div>
40
+ <span className="text-muted-foreground">Name:</span>{' '}
41
+ <span className="font-medium">{unpacked.flavor.name}</span>
42
+ </div>
43
+ <div>
44
+ <span className="text-muted-foreground">ID:</span>{' '}
45
+ <code className="font-mono">{unpacked.flavor.id}</code>
46
+ </div>
47
+ {unpacked.flavor.description && (
48
+ <div className="text-muted-foreground">{unpacked.flavor.description}</div>
49
+ )}
50
+ <div className="text-muted-foreground">
51
+ {unpacked.flavor.extensions.length} extensions ·{' '}
52
+ {unpacked.flavor.lenses.length} lenses ·{' '}
53
+ {unpacked.flavor.savedQueries.length} queries
54
+ </div>
55
+ {unpacked.summary && (
56
+ <div className="italic text-muted-foreground border-l-2 border-muted pl-2 mt-1">
57
+ {unpacked.summary}
58
+ </div>
59
+ )}
60
+ </div>
61
+ <div className="flex items-center justify-end gap-2">
62
+ <Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
63
+ Cancel
64
+ </Button>
65
+ <Button variant="ghost" size="sm" onClick={onMerge} disabled={busy}>
66
+ <GitMerge className="mr-1 h-3.5 w-3.5" />
67
+ Merge…
68
+ </Button>
69
+ <Button variant="outline" size="sm" onClick={onSaveAsNew} disabled={busy}>
70
+ <FilePlus className="mr-1 h-3.5 w-3.5" />
71
+ Save as new
72
+ </Button>
73
+ <Button size="sm" onClick={onReplace} disabled={busy}>
74
+ Replace existing
75
+ </Button>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,81 @@
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
+ * `FlavorIndicator` — status-bar chip showing the active flavor.
7
+ *
8
+ * Reads from the extension host's `FlavorService`. Re-renders on
9
+ * flavor changes (activate, import, switch). Clicking opens the
10
+ * flavor switcher — for Phase 3 that surfaces the export/import
11
+ * dialog; the merge UI lands in T13.
12
+ *
13
+ * Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §4.
14
+ */
15
+
16
+ import { useEffect, useState } from 'react';
17
+ import { Palette } from 'lucide-react';
18
+ import type { Flavor } from '@ifc-lite/extensions';
19
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
20
+
21
+ interface FlavorIndicatorProps {
22
+ onClick?: () => void;
23
+ }
24
+
25
+ export function FlavorIndicator({ onClick }: FlavorIndicatorProps) {
26
+ const host = useOptionalExtensionHost();
27
+ const [flavor, setFlavor] = useState<Flavor | undefined>();
28
+
29
+ useEffect(() => {
30
+ if (!host) return;
31
+ let cancelled = false;
32
+ const refresh = async () => {
33
+ try {
34
+ const next = await host.flavors.getActive();
35
+ if (!cancelled) setFlavor(next);
36
+ } catch (err) {
37
+ console.warn('[FlavorIndicator] getActive failed:', err);
38
+ }
39
+ };
40
+ void refresh();
41
+ const off = host.flavors.onChange(() => {
42
+ void refresh();
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ off();
47
+ };
48
+ }, [host]);
49
+
50
+ if (!host) return null;
51
+
52
+ const label = flavor?.name ?? 'Default';
53
+ // Slightly more emphasised treatment than the surrounding status
54
+ // bar items so the entry to the flavor system is visible without
55
+ // an animated walkthrough. Bordered chip + foreground text on
56
+ // active, muted on default.
57
+ return (
58
+ <button
59
+ type="button"
60
+ onClick={onClick}
61
+ aria-label={
62
+ flavor
63
+ ? `Active flavor: ${flavor.name}. Click to manage flavors.`
64
+ : 'No active flavor. Click to manage flavors.'
65
+ }
66
+ title={
67
+ flavor
68
+ ? `Flavors — switchable profiles of your extensions, lenses, queries, and overlay.\nActive: ${flavor.name}${flavor.description ? `\n${flavor.description}` : ''}\nClick to switch / export / import / merge.`
69
+ : 'Flavors — switchable profiles of your extensions, lenses, and settings.\nClick to manage.'
70
+ }
71
+ className={
72
+ flavor
73
+ ? 'flex items-center gap-1 rounded-md border border-primary/40 bg-primary/5 px-1.5 py-0.5 text-foreground hover:bg-primary/10 transition-colors'
74
+ : 'flex items-center gap-1 rounded-md border border-dashed border-muted-foreground/40 px-1.5 py-0.5 text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors'
75
+ }
76
+ >
77
+ <Palette className="h-3.5 w-3.5" />
78
+ <span className="max-w-[140px] truncate text-[11px] font-medium">{label}</span>
79
+ </button>
80
+ );
81
+ }