@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,318 @@
|
|
|
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
|
+
* `FlavorListView` — CRUD surface for the flavor library.
|
|
7
|
+
*
|
|
8
|
+
* Sits inside `FlavorDialog`; the dialog owns data fetching, busy
|
|
9
|
+
* state, and outgoing actions. Each row supports the full management
|
|
10
|
+
* loop a user actually needs:
|
|
11
|
+
*
|
|
12
|
+
* - **Activate** (non-active rows)
|
|
13
|
+
* - **Capture into THIS flavor** — snapshot the live viewer state
|
|
14
|
+
* into that specific flavor, not just the active one
|
|
15
|
+
* - **Rename** (inline click-to-edit on the name)
|
|
16
|
+
* - **Duplicate** — clone the flavor with a fresh id
|
|
17
|
+
* - **Export** / **Delete**
|
|
18
|
+
*
|
|
19
|
+
* Header offers **New flavor** (empty, name it) and **Save current as
|
|
20
|
+
* flavor** (snapshot from current viewer state, name it). Both flows
|
|
21
|
+
* open an inline name input so the user never sees an empty list with
|
|
22
|
+
* no path forward.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useState } from 'react';
|
|
26
|
+
import { Camera, Copy, Download, FilePlus, Pencil, RefreshCcw, Upload, X, Check } from 'lucide-react';
|
|
27
|
+
import type { Flavor } from '@ifc-lite/extensions';
|
|
28
|
+
import { Button } from '@/components/ui/button';
|
|
29
|
+
import { Input } from '@/components/ui/input';
|
|
30
|
+
|
|
31
|
+
interface FlavorListViewProps {
|
|
32
|
+
flavors: readonly Flavor[];
|
|
33
|
+
activeId: string | undefined;
|
|
34
|
+
busy: boolean;
|
|
35
|
+
/** Count of lenses currently in viewer state — surfaces "you have N lenses uncaptured" hint. */
|
|
36
|
+
liveLensCount: number;
|
|
37
|
+
onActivate(id: string): void;
|
|
38
|
+
onExport(id: string): void;
|
|
39
|
+
onDelete(id: string): void;
|
|
40
|
+
onImportClick(): void;
|
|
41
|
+
onReset(): void;
|
|
42
|
+
/** Snapshot current viewer state into a SPECIFIC flavor (not just active). */
|
|
43
|
+
onCaptureInto(id: string): void;
|
|
44
|
+
/** Rename a flavor. Caller validates. */
|
|
45
|
+
onRename(id: string, name: string): void;
|
|
46
|
+
/** Duplicate a flavor with a fresh id. */
|
|
47
|
+
onDuplicate(id: string): void;
|
|
48
|
+
/** Create a new flavor — empty body, user-provided name. Optional snapshot. */
|
|
49
|
+
onCreate(opts: { name: string; snapshot: boolean }): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Creating = null | { mode: 'empty' | 'snapshot'; name: string };
|
|
53
|
+
|
|
54
|
+
export function FlavorListView({
|
|
55
|
+
flavors,
|
|
56
|
+
activeId,
|
|
57
|
+
busy,
|
|
58
|
+
liveLensCount,
|
|
59
|
+
onActivate,
|
|
60
|
+
onExport,
|
|
61
|
+
onDelete,
|
|
62
|
+
onImportClick,
|
|
63
|
+
onReset,
|
|
64
|
+
onCaptureInto,
|
|
65
|
+
onRename,
|
|
66
|
+
onDuplicate,
|
|
67
|
+
onCreate,
|
|
68
|
+
}: FlavorListViewProps) {
|
|
69
|
+
const [creating, setCreating] = useState<Creating>(null);
|
|
70
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
71
|
+
const [renameValue, setRenameValue] = useState('');
|
|
72
|
+
|
|
73
|
+
const startRename = (flavor: Flavor) => {
|
|
74
|
+
setRenamingId(flavor.id);
|
|
75
|
+
setRenameValue(flavor.name);
|
|
76
|
+
};
|
|
77
|
+
const commitRename = () => {
|
|
78
|
+
if (renamingId && renameValue.trim().length > 0) {
|
|
79
|
+
onRename(renamingId, renameValue.trim());
|
|
80
|
+
}
|
|
81
|
+
setRenamingId(null);
|
|
82
|
+
};
|
|
83
|
+
const cancelRename = () => setRenamingId(null);
|
|
84
|
+
|
|
85
|
+
const submitCreate = () => {
|
|
86
|
+
if (!creating || creating.name.trim().length === 0) return;
|
|
87
|
+
onCreate({ name: creating.name.trim(), snapshot: creating.mode === 'snapshot' });
|
|
88
|
+
setCreating(null);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="space-y-3">
|
|
93
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
94
|
+
<div className="text-xs text-muted-foreground flex-1 min-w-[200px]">
|
|
95
|
+
Flavors bundle your extensions, lenses, queries, and layout. Switch to
|
|
96
|
+
isolate experiments; export to share or back up.
|
|
97
|
+
</div>
|
|
98
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
99
|
+
<Button
|
|
100
|
+
size="sm"
|
|
101
|
+
variant="default"
|
|
102
|
+
onClick={() => setCreating({ mode: liveLensCount > 0 ? 'snapshot' : 'empty', name: '' })}
|
|
103
|
+
disabled={busy || creating !== null}
|
|
104
|
+
aria-label={liveLensCount > 0 ? 'Save current setup as a new flavor' : 'Create a new empty flavor'}
|
|
105
|
+
>
|
|
106
|
+
<FilePlus className="mr-1 h-3.5 w-3.5" />
|
|
107
|
+
{liveLensCount > 0 ? 'Save current as flavor' : 'New flavor'}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button size="sm" variant="outline" onClick={onImportClick} disabled={busy}>
|
|
110
|
+
<Upload className="mr-1 h-3.5 w-3.5" />
|
|
111
|
+
Import
|
|
112
|
+
</Button>
|
|
113
|
+
<Button size="sm" variant="ghost" onClick={onReset} disabled={busy} title="Recreate the Default baseline flavor">
|
|
114
|
+
<RefreshCcw className="mr-1 h-3.5 w-3.5" />
|
|
115
|
+
Reset
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Inline name input — appears when user clicks "New flavor" or
|
|
121
|
+
"Save current as flavor". Keeping it inline avoids a nested
|
|
122
|
+
modal stack inside the Flavors dialog. */}
|
|
123
|
+
{creating && (
|
|
124
|
+
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
|
|
125
|
+
<label className="text-xs font-medium block mb-1">
|
|
126
|
+
{creating.mode === 'snapshot'
|
|
127
|
+
? `Name this flavor (will snapshot ${liveLensCount} lens${liveLensCount === 1 ? '' : 'es'})`
|
|
128
|
+
: 'Name this new empty flavor'}
|
|
129
|
+
</label>
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<Input
|
|
132
|
+
autoFocus
|
|
133
|
+
value={creating.name}
|
|
134
|
+
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
|
|
135
|
+
onKeyDown={(e) => {
|
|
136
|
+
if (e.key === 'Enter') submitCreate();
|
|
137
|
+
if (e.key === 'Escape') setCreating(null);
|
|
138
|
+
}}
|
|
139
|
+
placeholder={creating.mode === 'snapshot' ? 'Cost estimating' : 'Empty workspace'}
|
|
140
|
+
className="h-8 text-xs"
|
|
141
|
+
disabled={busy}
|
|
142
|
+
/>
|
|
143
|
+
{/* Snapshot/empty toggle so the user can switch mode without re-opening the form. */}
|
|
144
|
+
<Button
|
|
145
|
+
size="sm"
|
|
146
|
+
variant="ghost"
|
|
147
|
+
onClick={() => setCreating({
|
|
148
|
+
...creating,
|
|
149
|
+
mode: creating.mode === 'snapshot' ? 'empty' : 'snapshot',
|
|
150
|
+
})}
|
|
151
|
+
disabled={busy || liveLensCount === 0}
|
|
152
|
+
title={creating.mode === 'snapshot' ? 'Switch to empty flavor' : 'Switch to snapshot of current state'}
|
|
153
|
+
>
|
|
154
|
+
{creating.mode === 'snapshot' ? 'snapshot' : 'empty'}
|
|
155
|
+
</Button>
|
|
156
|
+
<Button size="sm" variant="default" onClick={submitCreate} disabled={busy || creating.name.trim().length === 0}>
|
|
157
|
+
Create
|
|
158
|
+
</Button>
|
|
159
|
+
<Button size="sm" variant="ghost" onClick={() => setCreating(null)} disabled={busy}>
|
|
160
|
+
Cancel
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{flavors.length === 0 ? (
|
|
167
|
+
<div className="rounded border bg-muted/30 px-4 py-6 text-center text-xs text-muted-foreground">
|
|
168
|
+
No flavors yet. Click <span className="font-medium">New flavor</span> above,
|
|
169
|
+
<span className="font-medium"> Reset</span> for the baseline, or
|
|
170
|
+
<span className="font-medium"> Import</span> a <code>.iflv</code>.
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<ul className="divide-y border rounded">
|
|
174
|
+
{flavors.map((flavor) => {
|
|
175
|
+
const isActive = flavor.id === activeId;
|
|
176
|
+
const isRenaming = renamingId === flavor.id;
|
|
177
|
+
const hasUncaptured = isActive && liveLensCount > flavor.lenses.length;
|
|
178
|
+
return (
|
|
179
|
+
<li
|
|
180
|
+
key={flavor.id}
|
|
181
|
+
className={`flex items-start gap-3 px-3 py-2 ${isActive ? 'bg-primary/5' : ''}`}
|
|
182
|
+
>
|
|
183
|
+
<div className="flex-1 min-w-0">
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
{isRenaming ? (
|
|
186
|
+
<>
|
|
187
|
+
<Input
|
|
188
|
+
autoFocus
|
|
189
|
+
value={renameValue}
|
|
190
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
191
|
+
onKeyDown={(e) => {
|
|
192
|
+
if (e.key === 'Enter') commitRename();
|
|
193
|
+
if (e.key === 'Escape') cancelRename();
|
|
194
|
+
}}
|
|
195
|
+
className="h-7 text-sm"
|
|
196
|
+
/>
|
|
197
|
+
<Button size="icon" variant="ghost" onClick={commitRename} aria-label="Save name">
|
|
198
|
+
<Check className="h-3.5 w-3.5" />
|
|
199
|
+
</Button>
|
|
200
|
+
<Button size="icon" variant="ghost" onClick={cancelRename} aria-label="Cancel rename">
|
|
201
|
+
<X className="h-3.5 w-3.5" />
|
|
202
|
+
</Button>
|
|
203
|
+
</>
|
|
204
|
+
) : (
|
|
205
|
+
<>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => startRename(flavor)}
|
|
209
|
+
className="text-sm font-medium hover:underline underline-offset-2 text-left truncate max-w-[14rem]"
|
|
210
|
+
aria-label={`Rename ${flavor.name}`}
|
|
211
|
+
title="Click to rename"
|
|
212
|
+
>
|
|
213
|
+
{flavor.name}
|
|
214
|
+
</button>
|
|
215
|
+
{isActive && (
|
|
216
|
+
<span className="text-[10px] uppercase tracking-wide bg-primary/20 text-primary rounded px-1.5 py-0.5 font-semibold">
|
|
217
|
+
Active
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
{hasUncaptured && (
|
|
221
|
+
<span
|
|
222
|
+
className="text-[10px] uppercase tracking-wide bg-amber-500/20 text-amber-700 dark:text-amber-300 rounded px-1.5 py-0.5 font-semibold"
|
|
223
|
+
title={`${liveLensCount - flavor.lenses.length} lens(es) in viewer not yet captured`}
|
|
224
|
+
>
|
|
225
|
+
{liveLensCount - flavor.lenses.length} uncaptured
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
</>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
<div className="text-[11px] text-muted-foreground font-mono break-all">
|
|
232
|
+
{flavor.id}
|
|
233
|
+
</div>
|
|
234
|
+
{flavor.description && (
|
|
235
|
+
<div className="text-[11px] text-muted-foreground mt-0.5">
|
|
236
|
+
{flavor.description}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
<div className="text-[10px] text-muted-foreground mt-0.5">
|
|
240
|
+
{flavor.extensions.length} ext · {flavor.lenses.length} lens ·{' '}
|
|
241
|
+
{flavor.savedQueries.length} qry · updated{' '}
|
|
242
|
+
{new Date(flavor.updatedAt).toLocaleDateString()}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
246
|
+
{!isActive && (
|
|
247
|
+
<Button
|
|
248
|
+
size="sm"
|
|
249
|
+
variant="ghost"
|
|
250
|
+
onClick={() => onActivate(flavor.id)}
|
|
251
|
+
disabled={busy}
|
|
252
|
+
>
|
|
253
|
+
Activate
|
|
254
|
+
</Button>
|
|
255
|
+
)}
|
|
256
|
+
<Button
|
|
257
|
+
size="icon"
|
|
258
|
+
variant={hasUncaptured ? 'default' : 'ghost'}
|
|
259
|
+
onClick={() => onCaptureInto(flavor.id)}
|
|
260
|
+
disabled={busy}
|
|
261
|
+
aria-label={`Capture current viewer state into ${flavor.name}`}
|
|
262
|
+
title={hasUncaptured
|
|
263
|
+
? `Save current viewer state into ${flavor.name} (${liveLensCount - flavor.lenses.length} new)`
|
|
264
|
+
: `Snapshot current viewer state into ${flavor.name}`}
|
|
265
|
+
>
|
|
266
|
+
<Camera className="h-3.5 w-3.5" />
|
|
267
|
+
</Button>
|
|
268
|
+
<Button
|
|
269
|
+
size="icon"
|
|
270
|
+
variant="ghost"
|
|
271
|
+
onClick={() => startRename(flavor)}
|
|
272
|
+
disabled={busy || isRenaming}
|
|
273
|
+
aria-label={`Rename ${flavor.name}`}
|
|
274
|
+
title="Rename"
|
|
275
|
+
>
|
|
276
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
277
|
+
</Button>
|
|
278
|
+
<Button
|
|
279
|
+
size="icon"
|
|
280
|
+
variant="ghost"
|
|
281
|
+
onClick={() => onDuplicate(flavor.id)}
|
|
282
|
+
disabled={busy}
|
|
283
|
+
aria-label={`Duplicate ${flavor.name}`}
|
|
284
|
+
title="Duplicate"
|
|
285
|
+
>
|
|
286
|
+
<Copy className="h-3.5 w-3.5" />
|
|
287
|
+
</Button>
|
|
288
|
+
<Button
|
|
289
|
+
size="icon"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
onClick={() => onExport(flavor.id)}
|
|
292
|
+
disabled={busy}
|
|
293
|
+
aria-label={`Export ${flavor.name}`}
|
|
294
|
+
title="Export as .iflv"
|
|
295
|
+
>
|
|
296
|
+
<Download className="h-3.5 w-3.5" />
|
|
297
|
+
</Button>
|
|
298
|
+
{!isActive && (
|
|
299
|
+
<Button
|
|
300
|
+
size="icon"
|
|
301
|
+
variant="ghost"
|
|
302
|
+
onClick={() => onDelete(flavor.id)}
|
|
303
|
+
disabled={busy}
|
|
304
|
+
aria-label={`Delete ${flavor.name}`}
|
|
305
|
+
title="Delete"
|
|
306
|
+
>
|
|
307
|
+
<X className="h-3.5 w-3.5" />
|
|
308
|
+
</Button>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</li>
|
|
312
|
+
);
|
|
313
|
+
})}
|
|
314
|
+
</ul>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
* `FlavorMergeDialog` — UI for the three-way flavor merge.
|
|
7
|
+
*
|
|
8
|
+
* The user opens this from the Flavor dialog or by importing an `.iflv`
|
|
9
|
+
* with the "merge" strategy. We:
|
|
10
|
+
*
|
|
11
|
+
* 1. Pick "ours" (defaults to the currently-active flavor) and
|
|
12
|
+
* "theirs" (the incoming flavor).
|
|
13
|
+
* 2. Call `mergeFlavors(base, theirs, ours)`. The base is whichever
|
|
14
|
+
* stored ancestor the import previewed — for the import-merge
|
|
15
|
+
* path it's the imported flavor's `id` matched in storage (best
|
|
16
|
+
* effort; otherwise we use `ours` as the base, which means
|
|
17
|
+
* conflicts surface as "their changes vs current").
|
|
18
|
+
* 3. Render each `MergeConflict` with a chooser
|
|
19
|
+
* (theirs / ours / base) and apply the user's selection to the
|
|
20
|
+
* merged result before saving.
|
|
21
|
+
* 4. Save the merged flavor as a new id (`<their-id>.merge-<ts>`)
|
|
22
|
+
* so neither input is overwritten silently.
|
|
23
|
+
*
|
|
24
|
+
* Phase 3 T13. Spec: docs/architecture/ai-customization/05-flavors-and-sharing.md §5.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
28
|
+
import { Check, GitMerge, X } from 'lucide-react';
|
|
29
|
+
import {
|
|
30
|
+
flavorMergedId,
|
|
31
|
+
mergeFlavors,
|
|
32
|
+
type Flavor,
|
|
33
|
+
type MergeConflict,
|
|
34
|
+
type MergeResult,
|
|
35
|
+
} from '@ifc-lite/extensions';
|
|
36
|
+
import { Button } from '@/components/ui/button';
|
|
37
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
38
|
+
import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
|
|
39
|
+
import { toast } from '@/components/ui/toast';
|
|
40
|
+
|
|
41
|
+
interface FlavorMergeDialogProps {
|
|
42
|
+
open: boolean;
|
|
43
|
+
/** The flavor being merged IN. */
|
|
44
|
+
theirs: Flavor | null;
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
/** Called after a successful merge so the parent can refresh. */
|
|
47
|
+
onMerged?: (merged: Flavor) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ConflictResolution = 'theirs' | 'ours' | 'base';
|
|
51
|
+
|
|
52
|
+
export function FlavorMergeDialog({ open, theirs, onClose, onMerged }: FlavorMergeDialogProps) {
|
|
53
|
+
const host = useExtensionHost();
|
|
54
|
+
const [ours, setOurs] = useState<Flavor | null>(null);
|
|
55
|
+
const [base, setBase] = useState<Flavor | null>(null);
|
|
56
|
+
const [busy, setBusy] = useState(false);
|
|
57
|
+
const [resolutions, setResolutions] = useState<Record<string, ConflictResolution>>({});
|
|
58
|
+
|
|
59
|
+
// Resolve "ours" = active flavor, "base" = stored copy of their id if
|
|
60
|
+
// present (otherwise fall back to ours so the merge degrades to a
|
|
61
|
+
// two-way merge surfacing every diff as a conflict).
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!open || !theirs) return;
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
void (async () => {
|
|
66
|
+
const active = await host.flavors.getActive();
|
|
67
|
+
const list = await host.flavors.list();
|
|
68
|
+
const stored = list.find((f) => f.id === theirs.id);
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
setOurs(active ?? null);
|
|
71
|
+
setBase(stored ?? active ?? null);
|
|
72
|
+
setResolutions({});
|
|
73
|
+
})();
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [open, theirs, host]);
|
|
78
|
+
|
|
79
|
+
const mergeResult = useMemo<MergeResult | null>(() => {
|
|
80
|
+
if (!theirs || !ours || !base) return null;
|
|
81
|
+
return mergeFlavors(base, theirs, ours);
|
|
82
|
+
}, [theirs, ours, base]);
|
|
83
|
+
|
|
84
|
+
const conflictKey = (c: MergeConflict): string => `${c.kind}:${c.key}`;
|
|
85
|
+
|
|
86
|
+
const handleApply = async () => {
|
|
87
|
+
if (!mergeResult || !theirs || !base) return;
|
|
88
|
+
setBusy(true);
|
|
89
|
+
try {
|
|
90
|
+
// Deep clone so applyChoice's index mutations on inner arrays
|
|
91
|
+
// don't reach back into the memoised mergeResult.
|
|
92
|
+
const merged: Flavor = {
|
|
93
|
+
...mergeResult.merged,
|
|
94
|
+
extensions: [...mergeResult.merged.extensions],
|
|
95
|
+
lenses: [...mergeResult.merged.lenses],
|
|
96
|
+
savedQueries: [...mergeResult.merged.savedQueries],
|
|
97
|
+
keybindings: [...mergeResult.merged.keybindings],
|
|
98
|
+
settings: { ...mergeResult.merged.settings },
|
|
99
|
+
};
|
|
100
|
+
// Apply per-conflict resolution: where the user picked theirs,
|
|
101
|
+
// we already merged in their value via the conflict choice;
|
|
102
|
+
// where they picked base we have to re-write the merged field.
|
|
103
|
+
// For v1 we honour the choice for extension version + setting
|
|
104
|
+
// values; lens/saved_query/keybinding stay on the default
|
|
105
|
+
// (ours wins) since list-id merge already picked sensibly.
|
|
106
|
+
for (const conflict of mergeResult.conflicts) {
|
|
107
|
+
const choice = resolutions[conflictKey(conflict)];
|
|
108
|
+
if (!choice || choice === 'ours') continue;
|
|
109
|
+
applyChoice(merged, conflict, choice, theirs, base);
|
|
110
|
+
}
|
|
111
|
+
merged.id = flavorMergedId(theirs.id);
|
|
112
|
+
merged.updatedAt = new Date().toISOString();
|
|
113
|
+
await host.flavors.put(merged, 'three-way merge');
|
|
114
|
+
toast.success(`Merged into ${merged.id}`);
|
|
115
|
+
onMerged?.(merged);
|
|
116
|
+
onClose();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
toast.error(`Merge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
} finally {
|
|
120
|
+
setBusy(false);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!theirs) return null;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
128
|
+
<DialogContent className="max-w-2xl">
|
|
129
|
+
<DialogHeader>
|
|
130
|
+
<DialogTitle className="flex items-center gap-2">
|
|
131
|
+
<GitMerge className="h-4 w-4" />
|
|
132
|
+
Merge flavor
|
|
133
|
+
</DialogTitle>
|
|
134
|
+
</DialogHeader>
|
|
135
|
+
|
|
136
|
+
{!ours || !base ? (
|
|
137
|
+
<div className="text-sm text-muted-foreground">
|
|
138
|
+
No active flavor — switch to a flavor first, then retry the merge.
|
|
139
|
+
</div>
|
|
140
|
+
) : !mergeResult ? (
|
|
141
|
+
<div className="text-sm text-muted-foreground">Computing merge…</div>
|
|
142
|
+
) : mergeResult.conflicts.length === 0 ? (
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
<div className="text-sm">
|
|
145
|
+
Clean merge — no conflicts between{' '}
|
|
146
|
+
<span className="font-medium">{theirs.name}</span> and{' '}
|
|
147
|
+
<span className="font-medium">{ours.name}</span>.
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex justify-end gap-2">
|
|
150
|
+
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>Cancel</Button>
|
|
151
|
+
<Button size="sm" onClick={() => void handleApply()} disabled={busy}>
|
|
152
|
+
<Check className="mr-1 h-3.5 w-3.5" />
|
|
153
|
+
Save merged flavor
|
|
154
|
+
</Button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<div className="space-y-3">
|
|
159
|
+
<div className="text-xs text-muted-foreground">
|
|
160
|
+
{mergeResult.conflicts.length} conflict
|
|
161
|
+
{mergeResult.conflicts.length === 1 ? '' : 's'} between{' '}
|
|
162
|
+
<span className="font-medium">{theirs.name}</span> (theirs) and{' '}
|
|
163
|
+
<span className="font-medium">{ours.name}</span> (ours).
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<ul className="divide-y border rounded max-h-[420px] overflow-y-auto">
|
|
167
|
+
{mergeResult.conflicts.map((conflict) => {
|
|
168
|
+
const key = conflictKey(conflict);
|
|
169
|
+
const choice = resolutions[key] ?? 'ours';
|
|
170
|
+
const hasBase = conflict.base !== undefined;
|
|
171
|
+
return (
|
|
172
|
+
<li key={key} className="px-3 py-2 space-y-1.5">
|
|
173
|
+
<div className="flex items-center gap-2 text-xs">
|
|
174
|
+
<code className="font-mono uppercase text-[10px] bg-muted rounded px-1.5 py-0.5">
|
|
175
|
+
{conflict.kind}
|
|
176
|
+
</code>
|
|
177
|
+
<span className="font-mono text-[11px] break-all">{conflict.key}</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div
|
|
180
|
+
className={`grid gap-2 text-[11px] ${hasBase ? 'grid-cols-3' : 'grid-cols-2'}`}
|
|
181
|
+
role="radiogroup"
|
|
182
|
+
aria-label={`Resolve ${conflict.kind} conflict on ${conflict.key}`}
|
|
183
|
+
>
|
|
184
|
+
<ResolutionChip
|
|
185
|
+
label="Theirs"
|
|
186
|
+
value={conflict.theirs}
|
|
187
|
+
active={choice === 'theirs'}
|
|
188
|
+
onClick={() => setResolutions((r) => ({ ...r, [key]: 'theirs' }))}
|
|
189
|
+
/>
|
|
190
|
+
<ResolutionChip
|
|
191
|
+
label="Ours"
|
|
192
|
+
value={conflict.ours}
|
|
193
|
+
active={choice === 'ours'}
|
|
194
|
+
onClick={() => setResolutions((r) => ({ ...r, [key]: 'ours' }))}
|
|
195
|
+
/>
|
|
196
|
+
{conflict.base !== undefined && (
|
|
197
|
+
<ResolutionChip
|
|
198
|
+
label="Base"
|
|
199
|
+
value={conflict.base}
|
|
200
|
+
active={choice === 'base'}
|
|
201
|
+
onClick={() => setResolutions((r) => ({ ...r, [key]: 'base' }))}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
</ul>
|
|
209
|
+
|
|
210
|
+
<div className="flex items-center justify-end gap-2 pt-1">
|
|
211
|
+
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>
|
|
212
|
+
<X className="mr-1 h-3.5 w-3.5" />
|
|
213
|
+
Cancel
|
|
214
|
+
</Button>
|
|
215
|
+
<Button size="sm" onClick={() => void handleApply()} disabled={busy}>
|
|
216
|
+
<Check className="mr-1 h-3.5 w-3.5" />
|
|
217
|
+
Save merged flavor
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</DialogContent>
|
|
223
|
+
</Dialog>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ResolutionChip({
|
|
228
|
+
label,
|
|
229
|
+
value,
|
|
230
|
+
active,
|
|
231
|
+
onClick,
|
|
232
|
+
}: {
|
|
233
|
+
label: string;
|
|
234
|
+
value: unknown;
|
|
235
|
+
active: boolean;
|
|
236
|
+
onClick: () => void;
|
|
237
|
+
}) {
|
|
238
|
+
return (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={onClick}
|
|
242
|
+
role="radio"
|
|
243
|
+
aria-checked={active}
|
|
244
|
+
aria-label={`Pick ${label}`}
|
|
245
|
+
className={`text-left rounded border px-2 py-1.5 transition-colors ${
|
|
246
|
+
active
|
|
247
|
+
? 'border-primary bg-primary/10'
|
|
248
|
+
: 'border-muted bg-muted/30 hover:bg-muted/50'
|
|
249
|
+
}`}
|
|
250
|
+
>
|
|
251
|
+
<div className="text-[10px] uppercase tracking-wide font-semibold text-muted-foreground">
|
|
252
|
+
{label}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="font-mono text-[10px] mt-0.5 break-all line-clamp-3">
|
|
255
|
+
{formatValue(value)}
|
|
256
|
+
</div>
|
|
257
|
+
</button>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatValue(value: unknown): string {
|
|
262
|
+
if (value === null || value === undefined) return '—';
|
|
263
|
+
if (typeof value === 'string') return value;
|
|
264
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
265
|
+
try {
|
|
266
|
+
return JSON.stringify(value);
|
|
267
|
+
} catch {
|
|
268
|
+
return '(unserialisable)';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Best-effort application of a non-default conflict choice onto a
|
|
274
|
+
* merged flavor. Covers the kinds where post-merge fixup is
|
|
275
|
+
* straightforward; complex shapes (lenses, queries, keybindings)
|
|
276
|
+
* already use list-id resolution at merge time.
|
|
277
|
+
*/
|
|
278
|
+
function applyChoice(
|
|
279
|
+
merged: Flavor,
|
|
280
|
+
conflict: MergeConflict,
|
|
281
|
+
choice: 'theirs' | 'base',
|
|
282
|
+
theirs: Flavor,
|
|
283
|
+
base: Flavor,
|
|
284
|
+
): void {
|
|
285
|
+
const source = choice === 'theirs' ? theirs : base;
|
|
286
|
+
switch (conflict.kind) {
|
|
287
|
+
case 'extension_version':
|
|
288
|
+
case 'extension_capabilities': {
|
|
289
|
+
const src = source.extensions.find((e) => e.id === conflict.key);
|
|
290
|
+
if (!src) return;
|
|
291
|
+
const idx = merged.extensions.findIndex((e) => e.id === conflict.key);
|
|
292
|
+
if (idx >= 0) {
|
|
293
|
+
merged.extensions[idx] = src;
|
|
294
|
+
} else {
|
|
295
|
+
merged.extensions = [...merged.extensions, src];
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case 'setting': {
|
|
300
|
+
merged.settings = { ...merged.settings, [conflict.key]: source.settings[conflict.key] };
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case 'lens': {
|
|
304
|
+
const src = source.lenses.find((l) => l.id === conflict.key);
|
|
305
|
+
if (!src) return;
|
|
306
|
+
const idx = merged.lenses.findIndex((l) => l.id === conflict.key);
|
|
307
|
+
if (idx >= 0) merged.lenses[idx] = src;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case 'saved_query': {
|
|
311
|
+
const src = source.savedQueries.find((q) => q.id === conflict.key);
|
|
312
|
+
if (!src) return;
|
|
313
|
+
const idx = merged.savedQueries.findIndex((q) => q.id === conflict.key);
|
|
314
|
+
if (idx >= 0) merged.savedQueries[idx] = src;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case 'keybinding': {
|
|
318
|
+
const [command, key] = conflict.key.split('@');
|
|
319
|
+
const src = source.keybindings.find((k) => k.command === command && k.key === key);
|
|
320
|
+
if (!src) return;
|
|
321
|
+
const idx = merged.keybindings.findIndex((k) => k.command === command && k.key === key);
|
|
322
|
+
if (idx >= 0) merged.keybindings[idx] = src;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|