@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,62 @@
|
|
|
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
|
+
* Error boundary for extension-authored widgets. Wraps the
|
|
7
|
+
* `WidgetRenderer` tree so a single malformed widget cannot crash
|
|
8
|
+
* the Extensions panel — instead the user sees a labelled fallback
|
|
9
|
+
* and can carry on using the rest of the panel.
|
|
10
|
+
*
|
|
11
|
+
* Designed as a *narrow* boundary: the host catches everything else
|
|
12
|
+
* via the dispatcher's per-command try/catch. This boundary covers
|
|
13
|
+
* render-time failures (bad bindings, missing fields, etc.).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
17
|
+
import { AlertCircle } from 'lucide-react';
|
|
18
|
+
|
|
19
|
+
interface WidgetErrorBoundaryProps {
|
|
20
|
+
/** Human-readable identifier used in the fallback (`extensionId` or `commandId`). */
|
|
21
|
+
label: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WidgetErrorBoundaryState {
|
|
26
|
+
error: Error | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class WidgetErrorBoundary extends Component<
|
|
30
|
+
WidgetErrorBoundaryProps,
|
|
31
|
+
WidgetErrorBoundaryState
|
|
32
|
+
> {
|
|
33
|
+
state: WidgetErrorBoundaryState = { error: null };
|
|
34
|
+
|
|
35
|
+
static getDerivedStateFromError(error: Error): WidgetErrorBoundaryState {
|
|
36
|
+
return { error };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
40
|
+
console.error(`[WidgetErrorBoundary] widget "${this.props.label}" crashed`, error, info);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(): ReactNode {
|
|
44
|
+
const { error } = this.state;
|
|
45
|
+
if (!error) return this.props.children;
|
|
46
|
+
return (
|
|
47
|
+
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs">
|
|
48
|
+
<div className="flex items-start gap-2">
|
|
49
|
+
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
|
50
|
+
<div className="min-w-0">
|
|
51
|
+
<div className="font-medium text-destructive">
|
|
52
|
+
{this.props.label} crashed while rendering
|
|
53
|
+
</div>
|
|
54
|
+
<div className="text-muted-foreground mt-0.5 font-mono break-words">
|
|
55
|
+
{error.message}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
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
|
+
* Widget DSL renderer.
|
|
7
|
+
*
|
|
8
|
+
* Walks a WidgetNode tree and emits matching React components. Data
|
|
9
|
+
* bindings (`"$.foo.bar"` or `"foo.bar"`) resolve against the state
|
|
10
|
+
* object the widget's handler returned.
|
|
11
|
+
*
|
|
12
|
+
* The renderer is intentionally minimal — chrome only, no inline
|
|
13
|
+
* styles, no client-defined CSS. Themes come from the host's Tailwind
|
|
14
|
+
* tokens; variants/tones get mapped at the leaf.
|
|
15
|
+
*
|
|
16
|
+
* Spec: docs/architecture/ai-customization/03-ui-surface.md §3.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useMemo } from 'react';
|
|
20
|
+
import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
|
21
|
+
import type {
|
|
22
|
+
ButtonNode,
|
|
23
|
+
ChartNode,
|
|
24
|
+
EmptyStateNode,
|
|
25
|
+
EntityListNode,
|
|
26
|
+
ErrorBannerNode,
|
|
27
|
+
FieldNode,
|
|
28
|
+
GroupNode,
|
|
29
|
+
KeyValueGridNode,
|
|
30
|
+
MarkdownNode,
|
|
31
|
+
SpinnerNode,
|
|
32
|
+
StackNode,
|
|
33
|
+
TableNode,
|
|
34
|
+
TabsNode,
|
|
35
|
+
TextNode,
|
|
36
|
+
TreeNode,
|
|
37
|
+
WidgetNode,
|
|
38
|
+
} from '@ifc-lite/extensions';
|
|
39
|
+
import { Button } from '@/components/ui/button';
|
|
40
|
+
import { Input } from '@/components/ui/input';
|
|
41
|
+
import { Label } from '@/components/ui/label';
|
|
42
|
+
import { Switch } from '@/components/ui/switch';
|
|
43
|
+
import { Separator } from '@/components/ui/separator';
|
|
44
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
45
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
46
|
+
import { cn } from '@/lib/utils';
|
|
47
|
+
|
|
48
|
+
export interface WidgetRendererContext {
|
|
49
|
+
/** State object the handler returned (provides data-binding values). */
|
|
50
|
+
state: unknown;
|
|
51
|
+
/** Invoke an extension command. The host dispatcher implementation. */
|
|
52
|
+
invokeCommand?: (commandId: string, args?: Record<string, unknown>) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WidgetRendererProps {
|
|
56
|
+
node: WidgetNode;
|
|
57
|
+
ctx: WidgetRendererContext;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function WidgetRenderer({ node, ctx }: WidgetRendererProps) {
|
|
61
|
+
switch (node.type) {
|
|
62
|
+
case 'Stack': return <RenderStack node={node} ctx={ctx} />;
|
|
63
|
+
case 'Group': return <RenderGroup node={node} ctx={ctx} />;
|
|
64
|
+
case 'Text': return <RenderText node={node} ctx={ctx} />;
|
|
65
|
+
case 'Field': return <RenderField node={node} ctx={ctx} />;
|
|
66
|
+
case 'Button': return <RenderButton node={node} ctx={ctx} />;
|
|
67
|
+
case 'Table': return <RenderTable node={node} ctx={ctx} />;
|
|
68
|
+
case 'Chart': return <RenderChart node={node} ctx={ctx} />;
|
|
69
|
+
case 'Markdown': return <RenderMarkdown node={node} ctx={ctx} />;
|
|
70
|
+
case 'Tabs': return <RenderTabs node={node} ctx={ctx} />;
|
|
71
|
+
case 'Separator': return <Separator className="my-2" />;
|
|
72
|
+
case 'EmptyState': return <RenderEmptyState node={node} ctx={ctx} />;
|
|
73
|
+
case 'Spinner': return <RenderSpinner node={node} ctx={ctx} />;
|
|
74
|
+
case 'ErrorBanner': return <RenderErrorBanner node={node} ctx={ctx} />;
|
|
75
|
+
case 'EntityList': return <RenderEntityList node={node} ctx={ctx} />;
|
|
76
|
+
case 'Tree': return <RenderTree node={node} ctx={ctx} />;
|
|
77
|
+
case 'KeyValueGrid': return <RenderKeyValueGrid node={node} ctx={ctx} />;
|
|
78
|
+
default:
|
|
79
|
+
return <UnknownNode node={node as { type?: string }} />;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Bindings
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Resolve a binding expression against the state. */
|
|
88
|
+
function resolveBinding(binding: string, state: unknown): unknown {
|
|
89
|
+
const path = binding.replace(/^\$\.?/, '');
|
|
90
|
+
if (!path) return state;
|
|
91
|
+
let cursor: unknown = state;
|
|
92
|
+
for (const segment of path.split('.')) {
|
|
93
|
+
if (cursor === null || cursor === undefined) return undefined;
|
|
94
|
+
if (typeof cursor !== 'object') return undefined;
|
|
95
|
+
cursor = (cursor as Record<string, unknown>)[segment];
|
|
96
|
+
}
|
|
97
|
+
return cursor;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asArray(value: unknown): unknown[] {
|
|
101
|
+
return Array.isArray(value) ? value : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Node renderers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function RenderStack({ node, ctx }: { node: StackNode; ctx: WidgetRendererContext }) {
|
|
109
|
+
const direction = node.direction === 'horizontal' ? 'flex-row' : 'flex-col';
|
|
110
|
+
const gap = node.gap === 'lg' ? 'gap-4' : node.gap === 'sm' ? 'gap-1' : node.gap === 'none' ? 'gap-0' : 'gap-2';
|
|
111
|
+
const align = node.align === 'center' ? 'items-center' : node.align === 'end' ? 'items-end' : node.align === 'stretch' ? 'items-stretch' : 'items-start';
|
|
112
|
+
const justify = node.justify === 'center' ? 'justify-center' : node.justify === 'end' ? 'justify-end' : node.justify === 'between' ? 'justify-between' : 'justify-start';
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('flex', direction, gap, align, justify)}>
|
|
115
|
+
{node.children.map((child, i) => (
|
|
116
|
+
<WidgetRenderer key={i} node={child} ctx={ctx} />
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function RenderGroup({ node, ctx }: { node: GroupNode; ctx: WidgetRendererContext }) {
|
|
123
|
+
return (
|
|
124
|
+
<fieldset className="rounded-md border p-3">
|
|
125
|
+
{node.title && <legend className="text-xs font-semibold px-1">{node.title}</legend>}
|
|
126
|
+
<div className="flex flex-col gap-2">
|
|
127
|
+
{node.children.map((child, i) => (
|
|
128
|
+
<WidgetRenderer key={i} node={child} ctx={ctx} />
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</fieldset>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function RenderText({ node }: { node: TextNode; ctx: WidgetRendererContext }) {
|
|
136
|
+
const variant = node.variant === 'heading' ? 'text-base font-semibold' : node.variant === 'caption' ? 'text-xs text-muted-foreground' : 'text-sm';
|
|
137
|
+
const tone = node.tone === 'error' ? 'text-destructive' : node.tone === 'warn' ? 'text-amber-600 dark:text-amber-400' : node.tone === 'success' ? 'text-emerald-600 dark:text-emerald-400' : node.tone === 'info' ? 'text-sky-600 dark:text-sky-400' : node.tone === 'muted' ? 'text-muted-foreground' : '';
|
|
138
|
+
return <p className={cn(variant, tone)}>{node.text}</p>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function RenderField({ node, ctx }: { node: FieldNode; ctx: WidgetRendererContext }) {
|
|
142
|
+
const value = resolveBinding(node.binding, ctx.state);
|
|
143
|
+
// Read-only render for v1 — Field's `binding` reflects state, and
|
|
144
|
+
// the handler decides how to update state on re-run via the
|
|
145
|
+
// command dispatcher. Inline editing without a host write-back path
|
|
146
|
+
// would imply an unenforced state contract.
|
|
147
|
+
switch (node.variant) {
|
|
148
|
+
case 'boolean':
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<Switch checked={Boolean(value)} disabled aria-label={node.label} />
|
|
152
|
+
<span className="text-xs">{node.label}</span>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
case 'number':
|
|
156
|
+
case 'text':
|
|
157
|
+
default:
|
|
158
|
+
return (
|
|
159
|
+
<div className="flex flex-col gap-1">
|
|
160
|
+
<Label className="text-[11px]">{node.label}</Label>
|
|
161
|
+
<Input
|
|
162
|
+
value={value === undefined || value === null ? '' : String(value)}
|
|
163
|
+
readOnly
|
|
164
|
+
placeholder={node.placeholder}
|
|
165
|
+
aria-label={node.label}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function RenderButton({ node, ctx }: { node: ButtonNode; ctx: WidgetRendererContext }) {
|
|
173
|
+
const variantMap = {
|
|
174
|
+
primary: 'default',
|
|
175
|
+
secondary: 'secondary',
|
|
176
|
+
destructive: 'destructive',
|
|
177
|
+
ghost: 'ghost',
|
|
178
|
+
} as const;
|
|
179
|
+
const variant = variantMap[node.variant ?? 'primary'];
|
|
180
|
+
return (
|
|
181
|
+
<Button
|
|
182
|
+
variant={variant}
|
|
183
|
+
disabled={Boolean(node.disabled)}
|
|
184
|
+
onClick={() => ctx.invokeCommand?.(node.command, node.args as Record<string, unknown> | undefined)}
|
|
185
|
+
>
|
|
186
|
+
{node.label}
|
|
187
|
+
</Button>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function RenderTable({ node, ctx }: { node: TableNode; ctx: WidgetRendererContext }) {
|
|
192
|
+
const rows = asArray(resolveBinding(node.data, ctx.state));
|
|
193
|
+
if (rows.length === 0) {
|
|
194
|
+
return <div className="text-xs text-muted-foreground italic px-2 py-3">No rows</div>;
|
|
195
|
+
}
|
|
196
|
+
return (
|
|
197
|
+
<ScrollArea className="max-h-72 rounded-md border">
|
|
198
|
+
<table className="w-full text-xs">
|
|
199
|
+
<thead className="border-b bg-muted/40 sticky top-0">
|
|
200
|
+
<tr>
|
|
201
|
+
{node.columns.map((c, i) => (
|
|
202
|
+
<th
|
|
203
|
+
key={i}
|
|
204
|
+
className={cn('px-2 py-1.5 text-left font-medium', c.align === 'right' && 'text-right', c.align === 'center' && 'text-center')}
|
|
205
|
+
style={c.width ? { width: c.width } : undefined}
|
|
206
|
+
>
|
|
207
|
+
{c.title}
|
|
208
|
+
</th>
|
|
209
|
+
))}
|
|
210
|
+
</tr>
|
|
211
|
+
</thead>
|
|
212
|
+
<tbody>
|
|
213
|
+
{rows.map((row, i) => (
|
|
214
|
+
<tr key={i} className="border-b last:border-0">
|
|
215
|
+
{node.columns.map((c, j) => {
|
|
216
|
+
const cell = (row as Record<string, unknown>)?.[c.field];
|
|
217
|
+
return (
|
|
218
|
+
<td
|
|
219
|
+
key={j}
|
|
220
|
+
className={cn('px-2 py-1', c.align === 'right' && 'text-right', c.align === 'center' && 'text-center')}
|
|
221
|
+
>
|
|
222
|
+
{cell === null || cell === undefined ? '' : String(cell)}
|
|
223
|
+
</td>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</tr>
|
|
227
|
+
))}
|
|
228
|
+
</tbody>
|
|
229
|
+
</table>
|
|
230
|
+
</ScrollArea>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function RenderChart({ node, ctx }: { node: ChartNode; ctx: WidgetRendererContext }) {
|
|
235
|
+
// Lightweight ASCII-bar chart for v1. Avoids pulling in a chart lib.
|
|
236
|
+
// Real charting can swap this implementation later without changing
|
|
237
|
+
// the DSL.
|
|
238
|
+
const rows = asArray(resolveBinding(node.data, ctx.state));
|
|
239
|
+
const xField = node.xField ?? 'label';
|
|
240
|
+
const yField = node.yField ?? 'value';
|
|
241
|
+
const max = useMemo(() => {
|
|
242
|
+
let m = 0;
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const v = Number((row as Record<string, unknown>)[yField] ?? 0);
|
|
245
|
+
if (Number.isFinite(v) && v > m) m = v;
|
|
246
|
+
}
|
|
247
|
+
return m || 1;
|
|
248
|
+
}, [rows, yField]);
|
|
249
|
+
return (
|
|
250
|
+
<div className="rounded-md border p-3 space-y-1.5">
|
|
251
|
+
<div className="text-[11px] text-muted-foreground">{node.variant} chart</div>
|
|
252
|
+
{rows.map((row, i) => {
|
|
253
|
+
const label = String((row as Record<string, unknown>)[xField] ?? '');
|
|
254
|
+
const v = Number((row as Record<string, unknown>)[yField] ?? 0);
|
|
255
|
+
const pct = (Math.max(0, v) / max) * 100;
|
|
256
|
+
return (
|
|
257
|
+
<div key={i} className="text-xs">
|
|
258
|
+
<div className="flex items-center justify-between mb-0.5">
|
|
259
|
+
<span className="truncate">{label}</span>
|
|
260
|
+
<span className="text-muted-foreground tabular-nums">{v}</span>
|
|
261
|
+
</div>
|
|
262
|
+
<div className="h-1.5 bg-muted rounded">
|
|
263
|
+
<div className="h-1.5 bg-primary rounded" style={{ width: `${pct}%` }} />
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
})}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function RenderMarkdown({ node }: { node: MarkdownNode; ctx: WidgetRendererContext }) {
|
|
273
|
+
// We render plain text only — no HTML, no parser. This preserves
|
|
274
|
+
// the "host renders chrome" invariant; rich markdown ships when
|
|
275
|
+
// we adopt a sanitising renderer in the host.
|
|
276
|
+
return <div className="text-xs whitespace-pre-wrap leading-relaxed">{node.content}</div>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function RenderTabs({ node, ctx }: { node: TabsNode; ctx: WidgetRendererContext }) {
|
|
280
|
+
const first = node.defaultTab ?? node.tabs[0]?.id;
|
|
281
|
+
return (
|
|
282
|
+
<Tabs defaultValue={first}>
|
|
283
|
+
<TabsList>
|
|
284
|
+
{node.tabs.map((tab) => (
|
|
285
|
+
<TabsTrigger key={tab.id} value={tab.id}>
|
|
286
|
+
{tab.label}
|
|
287
|
+
</TabsTrigger>
|
|
288
|
+
))}
|
|
289
|
+
</TabsList>
|
|
290
|
+
{node.tabs.map((tab) => (
|
|
291
|
+
<TabsContent key={tab.id} value={tab.id}>
|
|
292
|
+
<div className="flex flex-col gap-2">
|
|
293
|
+
{tab.children.map((child, i) => (
|
|
294
|
+
<WidgetRenderer key={i} node={child} ctx={ctx} />
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
</TabsContent>
|
|
298
|
+
))}
|
|
299
|
+
</Tabs>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function RenderEmptyState({ node, ctx }: { node: EmptyStateNode; ctx: WidgetRendererContext }) {
|
|
304
|
+
return (
|
|
305
|
+
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
|
306
|
+
<div className="text-sm font-medium">{node.heading}</div>
|
|
307
|
+
{node.body && <div className="text-xs text-muted-foreground max-w-md">{node.body}</div>}
|
|
308
|
+
{node.cta && (
|
|
309
|
+
<Button size="sm" onClick={() => ctx.invokeCommand?.(node.cta!.command)}>
|
|
310
|
+
{node.cta.label}
|
|
311
|
+
</Button>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function RenderSpinner({ node }: { node: SpinnerNode; ctx: WidgetRendererContext }) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
320
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
321
|
+
{node.label && <span>{node.label}</span>}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function RenderErrorBanner({ node, ctx }: { node: ErrorBannerNode; ctx: WidgetRendererContext }) {
|
|
327
|
+
return (
|
|
328
|
+
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs">
|
|
329
|
+
<AlertCircle className="h-3.5 w-3.5 text-destructive mt-0.5 shrink-0" />
|
|
330
|
+
<div className="flex-1">
|
|
331
|
+
<div className="text-destructive">{node.message}</div>
|
|
332
|
+
{node.retryCommand && (
|
|
333
|
+
<Button size="sm" variant="ghost" className="mt-1 h-7 px-2" onClick={() => ctx.invokeCommand?.(node.retryCommand!)}>
|
|
334
|
+
Retry
|
|
335
|
+
</Button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function RenderEntityList({ node, ctx }: { node: EntityListNode; ctx: WidgetRendererContext }) {
|
|
343
|
+
const rows = asArray(resolveBinding(node.data, ctx.state));
|
|
344
|
+
return (
|
|
345
|
+
<ul className="divide-y rounded-md border">
|
|
346
|
+
{rows.length === 0 && (
|
|
347
|
+
<li className="px-2 py-3 text-xs text-muted-foreground italic">No entities</li>
|
|
348
|
+
)}
|
|
349
|
+
{rows.map((row, i) => {
|
|
350
|
+
const r = row as Record<string, unknown>;
|
|
351
|
+
const id = String(r[node.idField] ?? '');
|
|
352
|
+
const label = node.labelField ? String(r[node.labelField] ?? id) : id;
|
|
353
|
+
return (
|
|
354
|
+
<li key={`${id}-${i}`} className="px-2 py-1.5 text-xs font-mono break-all">
|
|
355
|
+
{label}
|
|
356
|
+
</li>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</ul>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface TreeNodeData {
|
|
364
|
+
[key: string]: unknown;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function RenderTree({ node, ctx }: { node: TreeNode; ctx: WidgetRendererContext }) {
|
|
368
|
+
const roots = asArray(resolveBinding(node.data, ctx.state));
|
|
369
|
+
return (
|
|
370
|
+
<ul className="text-xs">
|
|
371
|
+
{roots.map((root, i) => (
|
|
372
|
+
<TreeItem key={i} node={root as TreeNodeData} labelField={node.labelField} childrenField={node.childrenField} depth={0} />
|
|
373
|
+
))}
|
|
374
|
+
</ul>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function TreeItem({ node, labelField, childrenField, depth }: {
|
|
379
|
+
node: TreeNodeData;
|
|
380
|
+
labelField: string;
|
|
381
|
+
childrenField: string;
|
|
382
|
+
depth: number;
|
|
383
|
+
}) {
|
|
384
|
+
const label = String(node[labelField] ?? '');
|
|
385
|
+
const children = asArray(node[childrenField]);
|
|
386
|
+
return (
|
|
387
|
+
<li>
|
|
388
|
+
<div className="flex items-center gap-1 py-0.5" style={{ paddingLeft: depth * 12 }}>
|
|
389
|
+
{children.length > 0 ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0 invisible" />}
|
|
390
|
+
<span className="truncate">{label}</span>
|
|
391
|
+
</div>
|
|
392
|
+
{children.length > 0 && (
|
|
393
|
+
<ul>
|
|
394
|
+
{children.map((child, i) => (
|
|
395
|
+
<TreeItem
|
|
396
|
+
key={i}
|
|
397
|
+
node={child as TreeNodeData}
|
|
398
|
+
labelField={labelField}
|
|
399
|
+
childrenField={childrenField}
|
|
400
|
+
depth={depth + 1}
|
|
401
|
+
/>
|
|
402
|
+
))}
|
|
403
|
+
</ul>
|
|
404
|
+
)}
|
|
405
|
+
</li>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function RenderKeyValueGrid({ node }: { node: KeyValueGridNode; ctx: WidgetRendererContext }) {
|
|
410
|
+
return (
|
|
411
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
|
412
|
+
{node.rows.map((row, i) => (
|
|
413
|
+
<div key={i} className="contents">
|
|
414
|
+
<dt className="text-muted-foreground">{row.label}</dt>
|
|
415
|
+
<dd className="break-all">{row.value}</dd>
|
|
416
|
+
</div>
|
|
417
|
+
))}
|
|
418
|
+
</dl>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function UnknownNode({ node }: { node: { type?: string } }) {
|
|
423
|
+
return (
|
|
424
|
+
<div className="text-xs text-destructive italic px-2 py-1">
|
|
425
|
+
Unknown widget node: <code>{String(node.type ?? '?')}</code>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|