@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,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
+ }