@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.
- package/.turbo/turbo-build.log +34 -31
- package/CHANGELOG.md +96 -0
- package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
- package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
- package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
- package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
- package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
- package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
- package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
- package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
- package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
- package/dist/assets/index-Bws3UAkj.css +1 -0
- package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
- package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
- package/dist/assets/lens-PYsLu_MA.js +1 -0
- package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
- package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
- package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
- package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
- package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
- package/dist/assets/raw-CoIXstQ-.js +1 -0
- package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
- package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
- package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
- package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
- package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
- package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +11 -9
- package/src/App.tsx +5 -2
- package/src/components/extensions/AuditLogPanel.tsx +259 -0
- package/src/components/extensions/BundlePreview.tsx +102 -0
- package/src/components/extensions/CapabilityReview.tsx +333 -0
- package/src/components/extensions/ExtensionDockHost.tsx +192 -0
- package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
- package/src/components/extensions/ExtensionsPanel.tsx +481 -0
- package/src/components/extensions/FlavorDialog.tsx +398 -0
- package/src/components/extensions/FlavorImportPreview.tsx +79 -0
- package/src/components/extensions/FlavorIndicator.tsx +81 -0
- package/src/components/extensions/FlavorListView.tsx +318 -0
- package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
- package/src/components/extensions/HelpHint.tsx +182 -0
- package/src/components/extensions/IdeasPanel.tsx +344 -0
- package/src/components/extensions/PlanCard.tsx +227 -0
- package/src/components/extensions/PrivacyPanel.tsx +312 -0
- package/src/components/extensions/PromoteToolDialog.tsx +313 -0
- package/src/components/extensions/RepairQueuePanel.tsx +222 -0
- package/src/components/extensions/icon-registry.ts +92 -0
- package/src/components/extensions/toast-helpers.ts +49 -0
- package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
- package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
- package/src/components/viewer/ChatPanel.tsx +251 -3
- package/src/components/viewer/CommandPalette.tsx +74 -4
- package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
- package/src/components/viewer/EntityContextMenu.tsx +70 -0
- package/src/components/viewer/ExportDialog.tsx +9 -1
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
- package/src/components/viewer/LensPanel.tsx +50 -0
- package/src/components/viewer/MainToolbar.tsx +170 -87
- package/src/components/viewer/ScriptPanel.tsx +105 -1
- package/src/components/viewer/Section2DPanel.tsx +58 -2
- package/src/components/viewer/StatusBar.tsx +18 -0
- package/src/components/viewer/ViewerLayout.tsx +53 -4
- package/src/components/viewer/Viewport.tsx +72 -0
- package/src/hooks/useActionLogger.test.ts +161 -0
- package/src/hooks/useActionLogger.ts +141 -0
- package/src/hooks/useForkExtension.ts +51 -0
- package/src/hooks/useIfcFederation.ts +7 -1
- package/src/hooks/useInstalledExtensions.ts +43 -0
- package/src/hooks/usePrivacyDisclosure.ts +48 -0
- package/src/hooks/useRunExtensionTests.ts +67 -0
- package/src/hooks/useSlotContributions.ts +38 -0
- package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
- package/src/hooks/useSymbolicAnnotations.ts +776 -0
- package/src/lib/desktop-product.ts +7 -1
- package/src/lib/lens/adapter.ts +14 -0
- package/src/lib/llm/prompt-cache.ts +77 -0
- package/src/lib/llm/stream-client.ts +20 -2
- package/src/lib/llm/stream-direct.ts +11 -1
- package/src/lib/llm/system-prompt.ts +42 -0
- package/src/lib/safe-mode.ts +30 -0
- package/src/sdk/ExtensionHostProvider.tsx +103 -0
- package/src/services/extensions/flavor-service.ts +183 -0
- package/src/services/extensions/host-commands.ts +112 -0
- package/src/services/extensions/host-installer.ts +289 -0
- package/src/services/extensions/host.ts +514 -0
- package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
- package/src/services/extensions/idb-flavor-storage.ts +241 -0
- package/src/services/extensions/idb-log-storage.test.ts +110 -0
- package/src/services/extensions/idb-log-storage.ts +171 -0
- package/src/services/extensions/idb-storage.ts +228 -0
- package/src/services/extensions/runtime-errors.ts +26 -0
- package/src/services/extensions/sandbox-factory.ts +217 -0
- package/src/store/constants.ts +48 -6
- package/src/store/index.ts +6 -1
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/extensionsSlice.ts +90 -0
- package/src/store/slices/lensSlice.ts +28 -0
- package/src/store/slices/visibilitySlice.test.ts +6 -0
- package/src/store/slices/visibilitySlice.ts +17 -8
- package/src/store/types.ts +2 -0
- package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
- package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
- package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
- package/dist/assets/index-DS_xJQfP.css +0 -1
- package/dist/assets/lens-CpjUdqpw.js +0 -1
- 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
|
+
}
|