@marimo-team/frontend 0.23.1-dev9 → 0.23.2-dev25
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/dist/assets/{JsonOutput-BY31ccA7.js → JsonOutput-CavtrueA.js} +1 -1
- package/dist/assets/{MarimoErrorOutput--Yd2Aw0J.js → MarimoErrorOutput-Bmp8DLLo.js} +1 -1
- package/dist/assets/RenderHTML-CM3WMmA8.js +1 -0
- package/dist/assets/{add-connection-dialog-CjvNOKgb.js → add-connection-dialog-BGZvJkor.js} +1 -1
- package/dist/assets/{agent-panel-C24uwabG.js → agent-panel-BvL9Lu9c.js} +1 -1
- package/dist/assets/{cell-editor-zW0u82sK.js → cell-editor-B40o_zx_.js} +1 -1
- package/dist/assets/{chat-display-DsHMZa9F.js → chat-display-M_nvYuHH.js} +1 -1
- package/dist/assets/{chat-panel-o9D3upnX.js → chat-panel-BMOW93uQ.js} +1 -1
- package/dist/assets/{chat-ui-BYS03y86.js → chat-ui-DyeimpVh.js} +1 -1
- package/dist/assets/{column-preview-Dwv5a_zE.js → column-preview-AfcgbFG_.js} +1 -1
- package/dist/assets/{command-palette-BYbKGSF3.js → command-palette-BgvdyU3B.js} +1 -1
- package/dist/assets/{documentation-panel-CA2pWMgB.js → documentation-panel-DUPcsi8P.js} +1 -1
- package/dist/assets/{edit-page-CMUN3ESy.js → edit-page-DD4uEDmX.js} +4 -4
- package/dist/assets/{error-panel-CbqfK1HJ.js → error-panel-DQOeSv5-.js} +1 -1
- package/dist/assets/{file-explorer-panel-CbS8z-JR.js → file-explorer-panel-B67zjs2X.js} +1 -1
- package/dist/assets/{form-DLyXacSF.js → form-BJ6VFU8l.js} +1 -1
- package/dist/assets/{hooks-kZJc1iBf.js → hooks-DvwShzDb.js} +1 -1
- package/dist/assets/index-y6osgSWB.js +42 -0
- package/dist/assets/{layout-tmN-U1zs.js → layout-erv8pLIP.js} +1 -1
- package/dist/assets/{panels-CLfdzLPR.js → panels-1u-RE72f.js} +1 -1
- package/dist/assets/{run-page-DPuH6QY4.js → run-page-DfWH_1mz.js} +1 -1
- package/dist/assets/{scratchpad-panel-BsMm0GQP.js → scratchpad-panel-CnaiXtoJ.js} +1 -1
- package/dist/assets/{session-panel-CTDzGShO.js → session-panel-C68GBFwH.js} +1 -1
- package/dist/assets/{snippets-panel-CWof0wHk.js → snippets-panel-BmIdR0lc.js} +1 -1
- package/dist/assets/state-D1n-olwf.js +3 -0
- package/dist/assets/{useNotebookActions-DHBEqrc_.js → useNotebookActions-Ch1o32Jw.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +4 -4
- package/src/core/islands/__tests__/bridge.test.ts +2 -12
- package/src/core/islands/__tests__/islands-harness.test.ts +348 -0
- package/src/core/islands/__tests__/parse.test.ts +466 -24
- package/src/core/islands/__tests__/test-utils.tsx +263 -0
- package/src/core/islands/bootstrap.ts +265 -0
- package/src/core/islands/bridge.ts +154 -75
- package/src/core/islands/components/IslandControls.tsx +103 -0
- package/src/core/islands/components/__tests__/IslandControls.test.tsx +185 -0
- package/src/core/islands/components/__tests__/useIslandControls.test.ts +208 -0
- package/src/core/islands/components/output-wrapper.tsx +76 -93
- package/src/core/islands/components/useIslandControls.ts +60 -0
- package/src/core/islands/components/web-components.tsx +168 -40
- package/src/core/islands/constants.ts +28 -0
- package/src/core/islands/main.ts +7 -205
- package/src/core/islands/parse.ts +73 -26
- package/src/core/islands/worker-factory.ts +86 -0
- package/src/plugins/core/RenderHTML.tsx +9 -0
- package/src/plugins/core/__test__/RenderHTML.test.ts +27 -0
- package/src/plugins/core/__test__/trusted-url.test.ts +48 -0
- package/src/plugins/core/registerReactComponent.tsx +11 -8
- package/src/plugins/core/trusted-url.ts +20 -0
- package/src/plugins/impl/ButtonPlugin.tsx +4 -6
- package/src/plugins/impl/CodeEditorPlugin.tsx +15 -18
- package/src/plugins/impl/DataEditorPlugin.tsx +8 -14
- package/src/plugins/impl/DataTablePlugin.tsx +8 -9
- package/src/plugins/impl/FileUploadPlugin.tsx +39 -43
- package/src/plugins/impl/FormPlugin.tsx +2 -6
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +27 -1
- package/src/plugins/impl/anywidget/widget-binding.ts +13 -0
- package/src/plugins/impl/chat/ChatPlugin.tsx +17 -20
- package/src/plugins/impl/data-explorer/DataExplorerPlugin.tsx +5 -8
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +21 -0
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +119 -0
- package/src/plugins/impl/panel/PanelPlugin.tsx +31 -10
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +60 -0
- package/src/plugins/impl/vega/VegaPlugin.tsx +5 -8
- package/src/plugins/layout/NavigationMenuPlugin.tsx +2 -6
- package/dist/assets/RenderHTML-CbuarQqA.js +0 -1
- package/dist/assets/index-Bm25ctN7.js +0 -42
- package/dist/assets/state-BvnlMKdT.js +0 -3
|
@@ -8,100 +8,154 @@ import type { JsonString } from "@/utils/json/base64";
|
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { generateUUID } from "@/utils/uuid";
|
|
10
10
|
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
|
|
11
|
-
import { getMarimoVersion } from "../meta/globals";
|
|
12
11
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
13
|
-
import { store } from "../state/jotai";
|
|
14
|
-
|
|
12
|
+
import { store as defaultStore } from "../state/jotai";
|
|
15
13
|
import { createMarimoFile, parseMarimoIslandApps } from "./parse";
|
|
16
14
|
import { islandsInitializedAtom } from "./state";
|
|
17
15
|
import type { WorkerSchema } from "./worker/worker";
|
|
18
|
-
import
|
|
16
|
+
import type { WorkerFactory } from "./worker-factory";
|
|
17
|
+
import { DefaultWorkerFactory } from "./worker-factory";
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Configuration for creating an IslandsPyodideBridge
|
|
21
|
+
*/
|
|
22
|
+
export interface IslandsBridgeConfig {
|
|
21
23
|
/**
|
|
22
|
-
*
|
|
24
|
+
* Optional worker factory for creating workers (for testing)
|
|
23
25
|
*/
|
|
24
|
-
|
|
25
|
-
const KEY = "_marimo_private_IslandsPyodideBridge";
|
|
26
|
-
if (!window[KEY]) {
|
|
27
|
-
window[KEY] = new IslandsPyodideBridge();
|
|
28
|
-
}
|
|
29
|
-
return window[KEY] as IslandsPyodideBridge;
|
|
30
|
-
}
|
|
26
|
+
workerFactory?: WorkerFactory;
|
|
31
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Optional Jotai store (for testing)
|
|
30
|
+
*/
|
|
31
|
+
store?: typeof defaultStore;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional root element for parsing islands (for testing)
|
|
35
|
+
*/
|
|
36
|
+
root?: Document | Element;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether to auto-start sessions on worker ready (default: true)
|
|
40
|
+
*/
|
|
41
|
+
autoStartSessions?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bridge between the browser and Pyodide worker for islands mode.
|
|
46
|
+
*
|
|
47
|
+
* This class manages communication with a Web Worker that runs Python code
|
|
48
|
+
* via Pyodide, enabling interactive marimo islands.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const bridge = new IslandsPyodideBridge();
|
|
53
|
+
* await bridge.initialized;
|
|
54
|
+
* bridge.consumeMessages(message => console.log(message));
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
32
58
|
private rpc: ReturnType<typeof getWorkerRPC<WorkerSchema>>;
|
|
33
59
|
private messageConsumer:
|
|
34
60
|
| ((message: JsonString<NotificationPayload>) => void)
|
|
35
61
|
| undefined;
|
|
62
|
+
private readonly store: typeof defaultStore;
|
|
63
|
+
private readonly root: Document | Element;
|
|
64
|
+
private readonly autoStartSessions: boolean;
|
|
36
65
|
|
|
37
66
|
public initialized = new Deferred<void>();
|
|
38
67
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const url = import.meta.env.DEV
|
|
44
|
-
? workerUrl
|
|
45
|
-
: makeRelativeWorkerUrl(workerUrl);
|
|
46
|
-
const js = `import ${JSON.stringify(new URL(url, import.meta.url))}`;
|
|
47
|
-
const blob = new Blob([js], { type: "application/javascript" });
|
|
48
|
-
const objURL = URL.createObjectURL(blob);
|
|
49
|
-
const worker = new Worker(
|
|
50
|
-
// oxlint-disable-next-line unicorn/relative-url-style
|
|
51
|
-
objURL,
|
|
52
|
-
{
|
|
53
|
-
type: "module",
|
|
54
|
-
// Pass the version to the worker
|
|
55
|
-
/* @vite-ignore */
|
|
56
|
-
name: getMarimoVersion(),
|
|
57
|
-
},
|
|
58
|
-
);
|
|
68
|
+
constructor(config: IslandsBridgeConfig = {}) {
|
|
69
|
+
this.store = config.store || defaultStore;
|
|
70
|
+
this.root = config.root || document;
|
|
71
|
+
this.autoStartSessions = config.autoStartSessions ?? true;
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
try {
|
|
74
|
+
const factory = config.workerFactory || new DefaultWorkerFactory();
|
|
75
|
+
const worker = factory.create();
|
|
76
|
+
this.rpc = getWorkerRPC<WorkerSchema>(worker);
|
|
77
|
+
this.setupMessageListeners();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
Logger.error("Failed to initialize IslandsPyodideBridge:", error);
|
|
80
|
+
this.initialized.reject(error);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Sets up message listeners for worker communication
|
|
87
|
+
*/
|
|
88
|
+
private setupMessageListeners(): void {
|
|
70
89
|
this.rpc.addMessageListener("ready", () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const file = createMarimoFile(app);
|
|
74
|
-
Logger.debug(file);
|
|
75
|
-
this.startSession({
|
|
76
|
-
code: file,
|
|
77
|
-
appId: app.id,
|
|
78
|
-
});
|
|
90
|
+
if (this.autoStartSessions) {
|
|
91
|
+
this.startSessionsForAllApps();
|
|
79
92
|
}
|
|
80
93
|
});
|
|
94
|
+
|
|
81
95
|
this.rpc.addMessageListener("initialized", () => {
|
|
82
|
-
store.set(islandsInitializedAtom, true);
|
|
96
|
+
this.store.set(islandsInitializedAtom, true);
|
|
83
97
|
this.initialized.resolve();
|
|
84
98
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
|
|
100
|
+
this.rpc.addMessageListener(
|
|
101
|
+
"initializedError",
|
|
102
|
+
({ error }: { error: string }) => {
|
|
103
|
+
Logger.error("Islands initialization error:", error);
|
|
104
|
+
this.store.set(islandsInitializedAtom, error);
|
|
105
|
+
this.initialized.reject(new Error(error));
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
this.rpc.addMessageListener(
|
|
110
|
+
"kernelMessage",
|
|
111
|
+
({ message }: { message: JsonString<NotificationPayload> }) => {
|
|
112
|
+
this.messageConsumer?.(message);
|
|
113
|
+
},
|
|
114
|
+
);
|
|
92
115
|
}
|
|
93
116
|
|
|
94
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Starts sessions for all apps found in the DOM
|
|
119
|
+
*/
|
|
120
|
+
private startSessionsForAllApps(): void {
|
|
121
|
+
const apps = parseMarimoIslandApps(this.root);
|
|
122
|
+
Logger.debug(
|
|
123
|
+
`Starting sessions for ${apps.length} app(s):`,
|
|
124
|
+
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
|
|
125
|
+
);
|
|
126
|
+
for (const app of apps) {
|
|
127
|
+
const file = createMarimoFile(app);
|
|
128
|
+
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
129
|
+
this.startSession({
|
|
130
|
+
code: file,
|
|
131
|
+
appId: app.id,
|
|
132
|
+
}).catch((error) => {
|
|
133
|
+
Logger.error(`Failed to start session for app ${app.id}:`, error);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Starts a new Python session for an app
|
|
140
|
+
*/
|
|
141
|
+
async startSession(opts: { code: string; appId: string }): Promise<void> {
|
|
95
142
|
await this.rpc.proxy.request.startSession(opts);
|
|
96
143
|
}
|
|
97
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Sets up a consumer for kernel messages
|
|
147
|
+
*/
|
|
98
148
|
consumeMessages(
|
|
99
149
|
consumer: (message: JsonString<NotificationPayload>) => void,
|
|
100
|
-
) {
|
|
150
|
+
): void {
|
|
101
151
|
this.messageConsumer = consumer;
|
|
102
152
|
this.rpc.proxy.send.consumerReady({});
|
|
103
153
|
}
|
|
104
154
|
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// RunRequests Implementation
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
105
159
|
sendComponentValues: RunRequests["sendComponentValues"] = async (
|
|
106
160
|
request,
|
|
107
161
|
): Promise<null> => {
|
|
@@ -113,9 +167,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
113
167
|
return null;
|
|
114
168
|
};
|
|
115
169
|
|
|
116
|
-
sendInstantiate: RunRequests["sendInstantiate"] = async (
|
|
117
|
-
request,
|
|
118
|
-
): Promise<null> => {
|
|
170
|
+
sendInstantiate: RunRequests["sendInstantiate"] = async (): Promise<null> => {
|
|
119
171
|
return null;
|
|
120
172
|
};
|
|
121
173
|
|
|
@@ -129,23 +181,31 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
129
181
|
return null;
|
|
130
182
|
};
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
|
|
184
|
+
sendModelValue: RunRequests["sendModelValue"] = async (request) => {
|
|
134
185
|
await this.putControlRequest({
|
|
135
|
-
type: "
|
|
186
|
+
type: "model",
|
|
136
187
|
...request,
|
|
137
188
|
});
|
|
138
189
|
return null;
|
|
139
190
|
};
|
|
140
191
|
|
|
141
|
-
|
|
192
|
+
// ============================================================================
|
|
193
|
+
// EditRequests Implementation
|
|
194
|
+
// ============================================================================
|
|
195
|
+
|
|
196
|
+
sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
|
|
197
|
+
await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
|
|
142
198
|
await this.putControlRequest({
|
|
143
|
-
type: "
|
|
199
|
+
type: "execute-cells",
|
|
144
200
|
...request,
|
|
145
201
|
});
|
|
146
202
|
return null;
|
|
147
203
|
};
|
|
148
204
|
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Not Implemented (Read-Only Mode)
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
149
209
|
getUsageStats = throwNotImplemented;
|
|
150
210
|
sendRename = throwNotImplemented;
|
|
151
211
|
sendSave = throwNotImplemented;
|
|
@@ -207,18 +267,37 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
207
267
|
|
|
208
268
|
// The kernel uses msgspec to parse control requests, which requires a 'type'
|
|
209
269
|
// field for discriminated union deserialization.
|
|
210
|
-
private async putControlRequest(operation: CommandMessage) {
|
|
270
|
+
private async putControlRequest(operation: CommandMessage): Promise<void> {
|
|
211
271
|
await this.rpc.proxy.request.bridge({
|
|
212
272
|
functionName: "put_control_request",
|
|
213
273
|
payload: operation,
|
|
214
274
|
});
|
|
215
275
|
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Cleans up resources (for testing)
|
|
279
|
+
*/
|
|
280
|
+
destroy(): void {
|
|
281
|
+
// Future: terminate worker if we own it
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Global singleton instance.
|
|
287
|
+
* Use `new IslandsPyodideBridge(config)` in tests for better isolation.
|
|
288
|
+
*/
|
|
289
|
+
let globalBridgeInstance: IslandsPyodideBridge | null = null;
|
|
290
|
+
|
|
291
|
+
export function getGlobalBridge(): IslandsPyodideBridge {
|
|
292
|
+
if (!globalBridgeInstance) {
|
|
293
|
+
globalBridgeInstance = new IslandsPyodideBridge();
|
|
294
|
+
}
|
|
295
|
+
return globalBridgeInstance;
|
|
216
296
|
}
|
|
217
297
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
: `./${url}`;
|
|
298
|
+
/**
|
|
299
|
+
* Resets the global bridge instance (for testing)
|
|
300
|
+
*/
|
|
301
|
+
export function resetGlobalBridge(): void {
|
|
302
|
+
globalBridgeInstance = null;
|
|
224
303
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { CopyIcon, PlayIcon } from "lucide-react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Tooltip } from "@/components/ui/tooltip";
|
|
7
|
+
import type { CellId } from "@/core/cells/ids";
|
|
8
|
+
import { useRequestClient } from "@/core/network/requests";
|
|
9
|
+
import { copyToClipboard } from "@/utils/copy";
|
|
10
|
+
import { Logger } from "@/utils/Logger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Props for IslandControls component
|
|
14
|
+
*/
|
|
15
|
+
export interface IslandControlsProps {
|
|
16
|
+
/**
|
|
17
|
+
* ID of the cell this control operates on
|
|
18
|
+
*/
|
|
19
|
+
cellId: CellId;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Callback to get the current code for the cell
|
|
23
|
+
*/
|
|
24
|
+
codeCallback: () => string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the controls should be visible
|
|
28
|
+
*/
|
|
29
|
+
visible: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Props for individual control buttons
|
|
34
|
+
*/
|
|
35
|
+
interface IconButtonProps {
|
|
36
|
+
tooltip: string;
|
|
37
|
+
icon: JSX.Element;
|
|
38
|
+
action: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A single icon button with tooltip
|
|
43
|
+
*/
|
|
44
|
+
const IconButton: React.FC<IconButtonProps> = ({ tooltip, icon, action }) => (
|
|
45
|
+
<Tooltip content={tooltip} delayDuration={200}>
|
|
46
|
+
<Button
|
|
47
|
+
size="icon"
|
|
48
|
+
variant="outline"
|
|
49
|
+
className="bg-background h-5 w-5 mb-0"
|
|
50
|
+
onClick={action}
|
|
51
|
+
>
|
|
52
|
+
{icon}
|
|
53
|
+
</Button>
|
|
54
|
+
</Tooltip>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Controls for interacting with an island cell.
|
|
59
|
+
*
|
|
60
|
+
* Provides buttons to:
|
|
61
|
+
* - Copy the cell's code to clipboard
|
|
62
|
+
* - Re-run the cell
|
|
63
|
+
*/
|
|
64
|
+
export const IslandControls: React.FC<IslandControlsProps> = ({
|
|
65
|
+
cellId,
|
|
66
|
+
codeCallback,
|
|
67
|
+
visible,
|
|
68
|
+
}) => {
|
|
69
|
+
const { sendRun } = useRequestClient();
|
|
70
|
+
|
|
71
|
+
const handleCopy = () => {
|
|
72
|
+
copyToClipboard(codeCallback());
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleRun = async () => {
|
|
76
|
+
try {
|
|
77
|
+
await sendRun({
|
|
78
|
+
cellIds: [cellId],
|
|
79
|
+
codes: [codeCallback()],
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
Logger.error("Failed to run cell:", error);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
className="absolute top-0 right-0 z-50 flex items-center justify-center gap-1"
|
|
89
|
+
style={{ display: visible ? "flex" : "none" }}
|
|
90
|
+
>
|
|
91
|
+
<IconButton
|
|
92
|
+
tooltip="Copy code"
|
|
93
|
+
icon={<CopyIcon className="size-3" />}
|
|
94
|
+
action={handleCopy}
|
|
95
|
+
/>
|
|
96
|
+
<IconButton
|
|
97
|
+
tooltip="Re-run cell"
|
|
98
|
+
icon={<PlayIcon className="size-3" />}
|
|
99
|
+
action={handleRun}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* Copyright 2024 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
6
|
+
import type { CellId } from "@/core/cells/ids";
|
|
7
|
+
import * as requestsModule from "@/core/network/requests";
|
|
8
|
+
import * as copyModule from "@/utils/copy";
|
|
9
|
+
import { IslandControls } from "../IslandControls";
|
|
10
|
+
|
|
11
|
+
// Mock the dependencies
|
|
12
|
+
vi.mock("@/core/network/requests", () => ({
|
|
13
|
+
useRequestClient: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@/utils/copy", () => ({
|
|
17
|
+
copyToClipboard: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe("IslandControls", () => {
|
|
21
|
+
const mockSendRun = vi.fn();
|
|
22
|
+
const mockCopyToClipboard = vi.fn();
|
|
23
|
+
const mockCodeCallback = vi.fn(() => "print('test code')");
|
|
24
|
+
const cellId = "test-cell-id" as CellId;
|
|
25
|
+
|
|
26
|
+
// Helper to render with required providers
|
|
27
|
+
const renderWithProviders = (component: React.ReactElement) => {
|
|
28
|
+
return render(<TooltipProvider>{component}</TooltipProvider>);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
mockSendRun.mockResolvedValue(undefined);
|
|
34
|
+
|
|
35
|
+
vi.spyOn(requestsModule, "useRequestClient").mockReturnValue({
|
|
36
|
+
sendRun: mockSendRun,
|
|
37
|
+
} as any);
|
|
38
|
+
|
|
39
|
+
vi.spyOn(copyModule, "copyToClipboard").mockImplementation(
|
|
40
|
+
mockCopyToClipboard,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should not display when visible is false", () => {
|
|
45
|
+
const { container } = renderWithProviders(
|
|
46
|
+
<IslandControls
|
|
47
|
+
cellId={cellId}
|
|
48
|
+
codeCallback={mockCodeCallback}
|
|
49
|
+
visible={false}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const controlsDiv = container.firstChild as HTMLElement;
|
|
54
|
+
expect(controlsDiv).toBeDefined();
|
|
55
|
+
expect(controlsDiv.style.display).toBe("none");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should display when visible is true", () => {
|
|
59
|
+
const { container } = renderWithProviders(
|
|
60
|
+
<IslandControls
|
|
61
|
+
cellId={cellId}
|
|
62
|
+
codeCallback={mockCodeCallback}
|
|
63
|
+
visible={true}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const controlsDiv = container.firstChild as HTMLElement;
|
|
68
|
+
expect(controlsDiv.style.display).toBe("flex");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should render copy and run buttons", () => {
|
|
72
|
+
renderWithProviders(
|
|
73
|
+
<IslandControls
|
|
74
|
+
cellId={cellId}
|
|
75
|
+
codeCallback={mockCodeCallback}
|
|
76
|
+
visible={true}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Should have 2 buttons (copy and run)
|
|
81
|
+
const buttons = screen.getAllByRole("button");
|
|
82
|
+
expect(buttons).toHaveLength(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should copy code to clipboard when copy button is clicked", async () => {
|
|
86
|
+
renderWithProviders(
|
|
87
|
+
<IslandControls
|
|
88
|
+
cellId={cellId}
|
|
89
|
+
codeCallback={mockCodeCallback}
|
|
90
|
+
visible={true}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const buttons = screen.getAllByRole("button");
|
|
95
|
+
const copyButton = buttons[0]; // First button is copy
|
|
96
|
+
|
|
97
|
+
fireEvent.click(copyButton);
|
|
98
|
+
|
|
99
|
+
expect(mockCodeCallback).toHaveBeenCalled();
|
|
100
|
+
expect(mockCopyToClipboard).toHaveBeenCalledWith("print('test code')");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should run cell when run button is clicked", async () => {
|
|
104
|
+
renderWithProviders(
|
|
105
|
+
<IslandControls
|
|
106
|
+
cellId={cellId}
|
|
107
|
+
codeCallback={mockCodeCallback}
|
|
108
|
+
visible={true}
|
|
109
|
+
/>,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const buttons = screen.getAllByRole("button");
|
|
113
|
+
const runButton = buttons[1]; // Second button is run
|
|
114
|
+
|
|
115
|
+
fireEvent.click(runButton);
|
|
116
|
+
|
|
117
|
+
// Wait for async operation
|
|
118
|
+
await vi.waitFor(() => {
|
|
119
|
+
expect(mockCodeCallback).toHaveBeenCalled();
|
|
120
|
+
expect(mockSendRun).toHaveBeenCalledWith({
|
|
121
|
+
cellIds: [cellId],
|
|
122
|
+
codes: ["print('test code')"],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should handle run errors gracefully", async () => {
|
|
128
|
+
const consoleErrorSpy = vi
|
|
129
|
+
.spyOn(console, "error")
|
|
130
|
+
.mockImplementation(() => {});
|
|
131
|
+
|
|
132
|
+
mockSendRun.mockRejectedValueOnce(new Error("Run failed"));
|
|
133
|
+
|
|
134
|
+
renderWithProviders(
|
|
135
|
+
<IslandControls
|
|
136
|
+
cellId={cellId}
|
|
137
|
+
codeCallback={mockCodeCallback}
|
|
138
|
+
visible={true}
|
|
139
|
+
/>,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const buttons = screen.getAllByRole("button");
|
|
143
|
+
const runButton = buttons[1];
|
|
144
|
+
|
|
145
|
+
fireEvent.click(runButton);
|
|
146
|
+
|
|
147
|
+
// Wait for error to be logged
|
|
148
|
+
await vi.waitFor(() => {
|
|
149
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
consoleErrorSpy.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should get fresh code on each button click", async () => {
|
|
156
|
+
let callCount = 0;
|
|
157
|
+
const dynamicCodeCallback = vi.fn(() => `code version ${++callCount}`);
|
|
158
|
+
|
|
159
|
+
renderWithProviders(
|
|
160
|
+
<IslandControls
|
|
161
|
+
cellId={cellId}
|
|
162
|
+
codeCallback={dynamicCodeCallback}
|
|
163
|
+
visible={true}
|
|
164
|
+
/>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const buttons = screen.getAllByRole("button");
|
|
168
|
+
const copyButton = buttons[0];
|
|
169
|
+
const runButton = buttons[1];
|
|
170
|
+
|
|
171
|
+
fireEvent.click(copyButton);
|
|
172
|
+
expect(mockCopyToClipboard).toHaveBeenCalledWith("code version 1");
|
|
173
|
+
|
|
174
|
+
fireEvent.click(runButton);
|
|
175
|
+
await vi.waitFor(() => {
|
|
176
|
+
expect(mockSendRun).toHaveBeenCalledWith({
|
|
177
|
+
cellIds: [cellId],
|
|
178
|
+
codes: ["code version 2"],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
fireEvent.click(copyButton);
|
|
183
|
+
expect(mockCopyToClipboard).toHaveBeenCalledWith("code version 3");
|
|
184
|
+
});
|
|
185
|
+
});
|