@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,192 @@
|
|
|
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
|
+
* `ExtensionDockHost` — render dock panels contributed by extensions.
|
|
7
|
+
*
|
|
8
|
+
* Consumes `dock.left | dock.right | dock.bottom` slot contributions
|
|
9
|
+
* and renders each as a tabbed panel. The body of each tab loads the
|
|
10
|
+
* referenced widget JSON from the contributing bundle and renders it
|
|
11
|
+
* via `WidgetRenderer`.
|
|
12
|
+
*
|
|
13
|
+
* Each dock slot is rendered separately so the caller can place them
|
|
14
|
+
* around the viewport layout independently. Empty slots render
|
|
15
|
+
* nothing (no chrome, no overhead).
|
|
16
|
+
*
|
|
17
|
+
* Spec: docs/architecture/ai-customization/03-ui-surface.md §3.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
21
|
+
import {
|
|
22
|
+
parseWhen,
|
|
23
|
+
evaluateWhen,
|
|
24
|
+
validateWidget,
|
|
25
|
+
type DockContribution,
|
|
26
|
+
type SlotContribution,
|
|
27
|
+
type WhenContext,
|
|
28
|
+
} from '@ifc-lite/extensions';
|
|
29
|
+
import { useSlotContributions } from '@/hooks/useSlotContributions';
|
|
30
|
+
import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
|
|
31
|
+
import { useViewerStore } from '@/store';
|
|
32
|
+
import { WidgetRenderer, type WidgetRendererContext } from './widget/WidgetRenderer';
|
|
33
|
+
import { WidgetErrorBoundary } from './widget/WidgetErrorBoundary';
|
|
34
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
35
|
+
import { toast } from '@/components/ui/toast';
|
|
36
|
+
import { describeRunCommandError } from '@/services/extensions/runtime-errors';
|
|
37
|
+
import { cn } from '@/lib/utils';
|
|
38
|
+
|
|
39
|
+
interface ExtensionDockHostProps {
|
|
40
|
+
slot: DockContribution['slot'];
|
|
41
|
+
/** Tailwind class to apply to the container. */
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ExtensionDockHost({ slot, className }: ExtensionDockHostProps) {
|
|
46
|
+
const contributions = useSlotContributions<DockContribution>(slot);
|
|
47
|
+
// Derive the when-clause context from live viewer state so
|
|
48
|
+
// contributions can key on selection / model presence. Future
|
|
49
|
+
// additions (schema, viewer.open, embed flag) thread through here.
|
|
50
|
+
const modelLoaded = useViewerStore((s) => s.models.size > 0);
|
|
51
|
+
// `selectedEntityIds` is the primary selection set — same source
|
|
52
|
+
// `ExtensionToolbarSlot` reads, so `selection.count` evaluates
|
|
53
|
+
// consistently across every extension surface.
|
|
54
|
+
const selectionCount = useViewerStore((s) => s.selectedEntityIds.size);
|
|
55
|
+
const whenContext = useMemo<WhenContext>(
|
|
56
|
+
() => ({ 'model.loaded': modelLoaded, 'selection.count': selectionCount }),
|
|
57
|
+
[modelLoaded, selectionCount],
|
|
58
|
+
);
|
|
59
|
+
const visible = useFiltered(contributions, whenContext);
|
|
60
|
+
const [activeId, setActiveId] = useState<string | undefined>(visible[0]?.payload.id);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!visible.find((v) => v.payload.id === activeId)) {
|
|
64
|
+
setActiveId(visible[0]?.payload.id);
|
|
65
|
+
}
|
|
66
|
+
}, [visible, activeId]);
|
|
67
|
+
|
|
68
|
+
if (visible.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
const active = visible.find((v) => v.payload.id === activeId) ?? visible[0];
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={cn('flex flex-col h-full border-t bg-background', className)} role="region" aria-label={`Extension dock (${slot})`}>
|
|
74
|
+
<div className="flex items-center gap-0 border-b overflow-x-auto" role="tablist">
|
|
75
|
+
{visible.map((c) => {
|
|
76
|
+
const isActive = c.payload.id === active.payload.id;
|
|
77
|
+
return (
|
|
78
|
+
<button
|
|
79
|
+
key={`${c.extensionId}:${c.payload.id}`}
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setActiveId(c.payload.id)}
|
|
82
|
+
role="tab"
|
|
83
|
+
aria-selected={isActive}
|
|
84
|
+
aria-controls={`dock-panel-${c.payload.id}`}
|
|
85
|
+
className={cn(
|
|
86
|
+
'shrink-0 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors',
|
|
87
|
+
isActive
|
|
88
|
+
? 'border-primary text-primary'
|
|
89
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
90
|
+
)}
|
|
91
|
+
title={`${c.payload.title} — ${c.extensionId}`}
|
|
92
|
+
>
|
|
93
|
+
{c.payload.title}
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
<ScrollArea className="flex-1">
|
|
99
|
+
<DockBody contribution={active} />
|
|
100
|
+
</ScrollArea>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function useFiltered(
|
|
106
|
+
contributions: SlotContribution<DockContribution>[],
|
|
107
|
+
whenContext: WhenContext,
|
|
108
|
+
): SlotContribution<DockContribution>[] {
|
|
109
|
+
return useMemo(() => {
|
|
110
|
+
return contributions.filter((c) => {
|
|
111
|
+
if (!c.payload.when) return true;
|
|
112
|
+
const parsed = parseWhen(c.payload.when);
|
|
113
|
+
if (!parsed.ok) return false;
|
|
114
|
+
return evaluateWhen(parsed.value, whenContext);
|
|
115
|
+
});
|
|
116
|
+
}, [contributions, whenContext]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function DockBody({ contribution }: { contribution: SlotContribution<DockContribution> }) {
|
|
120
|
+
const host = useExtensionHost();
|
|
121
|
+
const [widget, setWidget] = useState<unknown>();
|
|
122
|
+
const [error, setError] = useState<string | undefined>();
|
|
123
|
+
const ctx: WidgetRendererContext = useMemo(
|
|
124
|
+
() => ({
|
|
125
|
+
state: {},
|
|
126
|
+
invokeCommand: (commandId: string) => {
|
|
127
|
+
host.runCommand(commandId).catch((err) => {
|
|
128
|
+
console.warn('[ExtensionDockHost] command failed:', err);
|
|
129
|
+
toast.error(describeRunCommandError(commandId, err));
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
[host],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
let cancelled = false;
|
|
138
|
+
void (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const bundle = host.loader.getBundle(contribution.extensionId);
|
|
141
|
+
if (!bundle) {
|
|
142
|
+
if (!cancelled) setError(`Bundle for ${contribution.extensionId} not loaded.`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const file = bundle.files.get(contribution.payload.widget);
|
|
146
|
+
if (!file) {
|
|
147
|
+
if (!cancelled) setError(`Widget "${contribution.payload.widget}" not found in bundle.`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const text = file.text ?? new TextDecoder().decode(file.bytes);
|
|
151
|
+
const json = JSON.parse(text);
|
|
152
|
+
// Validate the shape before handing it to the renderer so we
|
|
153
|
+
// surface a clean structured error instead of a deep crash.
|
|
154
|
+
const validated = validateWidget(json, contribution.payload.widget);
|
|
155
|
+
if (!validated.ok) {
|
|
156
|
+
const first = validated.errors[0];
|
|
157
|
+
if (!cancelled) setError(`Widget ${first?.path || ''} ${first?.message ?? 'failed validation'}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!cancelled) setWidget(validated.value);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
};
|
|
168
|
+
}, [host, contribution]);
|
|
169
|
+
|
|
170
|
+
if (error) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="p-3 text-xs text-rose-600 dark:text-rose-400">
|
|
173
|
+
{error}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!widget) {
|
|
178
|
+
return <div className="p-3 text-xs text-muted-foreground">Loading widget…</div>;
|
|
179
|
+
}
|
|
180
|
+
return (
|
|
181
|
+
<div className="p-3">
|
|
182
|
+
<WidgetErrorBoundary
|
|
183
|
+
label={`${contribution.extensionId}/${contribution.payload.widget}`}
|
|
184
|
+
>
|
|
185
|
+
<WidgetRenderer
|
|
186
|
+
node={widget as Parameters<typeof WidgetRenderer>[0]['node']}
|
|
187
|
+
ctx={ctx}
|
|
188
|
+
/>
|
|
189
|
+
</WidgetErrorBoundary>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
* `ExtensionToolbarSlot` — render extension toolbar contributions.
|
|
7
|
+
*
|
|
8
|
+
* Reads contributions from the slot registry. Each contribution
|
|
9
|
+
* declares a slot (`toolbar.left | toolbar.right | toolbar.center`)
|
|
10
|
+
* and a command id. Clicking dispatches through the host's command
|
|
11
|
+
* runner.
|
|
12
|
+
*
|
|
13
|
+
* Ordering: by `order` ascending; ties broken by command id alpha.
|
|
14
|
+
* Visibility: respects the `when` clause if present (evaluated against
|
|
15
|
+
* a minimal viewer context — model loaded / selection count / etc).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo } from 'react';
|
|
19
|
+
import type { ResolvedToolbarContribution } from '@ifc-lite/extensions';
|
|
20
|
+
import { evaluateWhen, parseWhen } from '@ifc-lite/extensions';
|
|
21
|
+
import { Button } from '@/components/ui/button';
|
|
22
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
23
|
+
import { toast } from '@/components/ui/toast';
|
|
24
|
+
import { describeRunCommandError } from '@/services/extensions/runtime-errors';
|
|
25
|
+
import { useSlotContributions } from '@/hooks/useSlotContributions';
|
|
26
|
+
import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
|
|
27
|
+
import { useViewerStore } from '@/store';
|
|
28
|
+
import { resolveExtensionIcon } from './icon-registry';
|
|
29
|
+
|
|
30
|
+
interface ExtensionToolbarSlotProps {
|
|
31
|
+
slot: 'toolbar.left' | 'toolbar.right' | 'toolbar.center';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ExtensionToolbarSlot({ slot }: ExtensionToolbarSlotProps) {
|
|
35
|
+
const host = useOptionalExtensionHost();
|
|
36
|
+
// Loader enriches the toolbar payload with the linked command's
|
|
37
|
+
// `icon` + `title` — see manifestToContributions in loader.ts.
|
|
38
|
+
const contributions = useSlotContributions<ResolvedToolbarContribution>(slot);
|
|
39
|
+
|
|
40
|
+
const models = useViewerStore((s) => s.models);
|
|
41
|
+
const selectedCount = useViewerStore((s) => s.selectedEntityIds.size);
|
|
42
|
+
|
|
43
|
+
const whenContext = useMemo(() => ({
|
|
44
|
+
'model.loaded': models.size > 0,
|
|
45
|
+
'model.schema': undefined,
|
|
46
|
+
'model.count': models.size,
|
|
47
|
+
'selection.count': selectedCount,
|
|
48
|
+
'selection.type': undefined,
|
|
49
|
+
'viewer.open': true,
|
|
50
|
+
desktop: false,
|
|
51
|
+
embed: false,
|
|
52
|
+
}), [models.size, selectedCount]);
|
|
53
|
+
|
|
54
|
+
const visible = useMemo(() => {
|
|
55
|
+
return contributions
|
|
56
|
+
.filter((c) => {
|
|
57
|
+
const when = c.payload.when;
|
|
58
|
+
if (!when) return true;
|
|
59
|
+
const parsed = parseWhen(when);
|
|
60
|
+
if (!parsed.ok) return false;
|
|
61
|
+
return evaluateWhen(parsed.value, whenContext);
|
|
62
|
+
})
|
|
63
|
+
.sort((a, b) => {
|
|
64
|
+
const oa = a.payload.order ?? 100;
|
|
65
|
+
const ob = b.payload.order ?? 100;
|
|
66
|
+
if (oa !== ob) return oa - ob;
|
|
67
|
+
return a.payload.command.localeCompare(b.payload.command);
|
|
68
|
+
});
|
|
69
|
+
}, [contributions, whenContext]);
|
|
70
|
+
|
|
71
|
+
if (visible.length === 0 || !host) return null;
|
|
72
|
+
|
|
73
|
+
const handleClick = (commandId: string) => {
|
|
74
|
+
void host.dispatcher
|
|
75
|
+
.fire(`onCommand:${commandId}` as `onCommand:${string}`)
|
|
76
|
+
.then(() => host.runCommand(commandId))
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
toast.error(describeRunCommandError(commandId, err));
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-center gap-1">
|
|
84
|
+
{visible.map((c) => {
|
|
85
|
+
const cmd = c.payload.command;
|
|
86
|
+
const title = c.payload.title ?? cmd;
|
|
87
|
+
const Icon = resolveExtensionIcon(c.payload.icon);
|
|
88
|
+
return (
|
|
89
|
+
<Tooltip key={`${c.extensionId}:${cmd}`}>
|
|
90
|
+
<TooltipTrigger asChild>
|
|
91
|
+
<Button
|
|
92
|
+
size="icon"
|
|
93
|
+
variant="ghost"
|
|
94
|
+
onClick={() => handleClick(cmd)}
|
|
95
|
+
aria-label={`Run ${title}`}
|
|
96
|
+
>
|
|
97
|
+
<Icon className="h-4 w-4" />
|
|
98
|
+
</Button>
|
|
99
|
+
</TooltipTrigger>
|
|
100
|
+
<TooltipContent>{title}</TooltipContent>
|
|
101
|
+
</Tooltip>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|