@marimo-team/islands 0.23.3-dev8 → 0.23.3
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/{chat-ui-BLFhPclV.js → chat-ui-DEd_Ndal.js} +82 -82
- package/dist/{html-to-image-XYwXqg2E.js → html-to-image-DBosi5GK.js} +2240 -2214
- package/dist/main.js +2627 -2746
- package/dist/{process-output-BDVjDpbu.js → process-output-k-4WHpxz.js} +1 -1
- package/dist/{reveal-component-CrnLosc4.js → reveal-component-CFuofbBD.js} +827 -561
- package/dist/{slide-Dl7Rf496.js → slide-form-DgMI37ES.js} +1729 -894
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/output/JsonOutput.tsx +187 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +185 -2
- package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
- package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
- package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
- package/src/components/editor/renderers/slides-layout/types.ts +31 -3
- package/src/components/editor/renderers/types.ts +2 -0
- package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
- package/src/components/slides/compose-slides.ts +337 -0
- package/src/components/slides/minimap.tsx +133 -12
- package/src/components/slides/reveal-component.tsx +337 -74
- package/src/components/slides/reveal-slides.css +33 -1
- package/src/components/slides/slide-form.tsx +347 -0
- package/src/components/ui/radio-group.tsx +5 -3
- package/src/core/cells/types.ts +2 -0
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/layout/layout.ts +6 -2
- package/src/core/static/__tests__/export-context.test.ts +122 -0
- package/src/core/static/__tests__/static-state.test.ts +80 -0
- package/src/core/static/export-context.ts +84 -0
- package/src/core/static/static-state.ts +44 -6
- package/src/plugins/core/RenderHTML.tsx +23 -2
- package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
- package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
- package/src/plugins/core/sanitize.ts +11 -5
- package/src/plugins/core/trusted-url.ts +32 -10
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EyeOffIcon,
|
|
5
|
+
LayoutTemplateIcon,
|
|
6
|
+
type LucideIcon,
|
|
7
|
+
Rows2Icon,
|
|
8
|
+
CookieIcon,
|
|
9
|
+
PanelRightCloseIcon,
|
|
10
|
+
PanelRightOpenIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "@/components/ui/select";
|
|
20
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
21
|
+
import type { CellId } from "@/core/cells/ids";
|
|
22
|
+
import { cn } from "@/utils/cn";
|
|
23
|
+
import type {
|
|
24
|
+
DeckTransition,
|
|
25
|
+
SlidesLayout,
|
|
26
|
+
SlideType,
|
|
27
|
+
} from "../editor/renderers/slides-layout/types";
|
|
28
|
+
import { useState } from "react";
|
|
29
|
+
import { Tooltip } from "../ui/tooltip";
|
|
30
|
+
import { Button } from "../ui/button";
|
|
31
|
+
import type { RuntimeCell } from "@/core/cells/types";
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_SLIDE_TYPE: SlideType = "slide";
|
|
34
|
+
export const DEFAULT_DECK_TRANSITION: DeckTransition = "slide";
|
|
35
|
+
const COLLAPSED_CONFIG_WIDTH = 36;
|
|
36
|
+
|
|
37
|
+
export interface SlideTypeOption {
|
|
38
|
+
value: SlideType;
|
|
39
|
+
label: string;
|
|
40
|
+
description: string;
|
|
41
|
+
Icon: LucideIcon;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SLIDE_TYPE_OPTIONS: readonly SlideTypeOption[] = [
|
|
45
|
+
{
|
|
46
|
+
value: "slide",
|
|
47
|
+
label: "Slide",
|
|
48
|
+
description:
|
|
49
|
+
"A new top-level slide. Advances horizontally with the right arrow.",
|
|
50
|
+
Icon: LayoutTemplateIcon,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
value: "sub-slide",
|
|
54
|
+
label: "Sub-slide",
|
|
55
|
+
description:
|
|
56
|
+
"Stacks vertically under the previous slide. Reached with the down arrow.",
|
|
57
|
+
Icon: Rows2Icon,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: "fragment",
|
|
61
|
+
label: "Fragment",
|
|
62
|
+
description: "Reveals step-by-step on the current slide without advancing.",
|
|
63
|
+
Icon: CookieIcon,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
value: "skip",
|
|
67
|
+
label: "Skip",
|
|
68
|
+
description:
|
|
69
|
+
"Hidden from the presentation. Still visible here in the editor.",
|
|
70
|
+
Icon: EyeOffIcon,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Lookup form of {@link SLIDE_TYPE_OPTIONS} for O(1) access by `SlideType`.
|
|
76
|
+
*/
|
|
77
|
+
export const SLIDE_TYPE_OPTIONS_BY_VALUE: Readonly<
|
|
78
|
+
Record<SlideType, SlideTypeOption>
|
|
79
|
+
> = Object.fromEntries(
|
|
80
|
+
SLIDE_TYPE_OPTIONS.map((option) => [option.value, option]),
|
|
81
|
+
) as Record<SlideType, SlideTypeOption>;
|
|
82
|
+
|
|
83
|
+
interface DeckTransitionOption {
|
|
84
|
+
value: DeckTransition;
|
|
85
|
+
label: string;
|
|
86
|
+
description: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DECK_TRANSITION_OPTIONS: DeckTransitionOption[] = [
|
|
90
|
+
{ value: "none", label: "None", description: "No animation between slides." },
|
|
91
|
+
{ value: "fade", label: "Fade", description: "Cross-fade between slides." },
|
|
92
|
+
{
|
|
93
|
+
value: "slide",
|
|
94
|
+
label: "Slide",
|
|
95
|
+
description: "Slides move horizontally / vertically.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
value: "convex",
|
|
99
|
+
label: "Convex",
|
|
100
|
+
description: "Rotate with a convex curve.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
value: "concave",
|
|
104
|
+
label: "Concave",
|
|
105
|
+
description: "Rotate with a concave curve.",
|
|
106
|
+
},
|
|
107
|
+
{ value: "zoom", label: "Zoom", description: "Zoom into the next slide." },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const SlidesForm = ({
|
|
111
|
+
layout,
|
|
112
|
+
setLayout,
|
|
113
|
+
cellId,
|
|
114
|
+
}: {
|
|
115
|
+
layout: SlidesLayout;
|
|
116
|
+
setLayout: (layout: SlidesLayout) => void;
|
|
117
|
+
cellId: CellId;
|
|
118
|
+
}) => {
|
|
119
|
+
return (
|
|
120
|
+
<Tabs defaultValue="slide" className="flex flex-col flex-1 p-3 gap-3">
|
|
121
|
+
<TabsList className="grid grid-cols-2">
|
|
122
|
+
<TabsTrigger value="slide">Slide</TabsTrigger>
|
|
123
|
+
<TabsTrigger value="deck">Deck</TabsTrigger>
|
|
124
|
+
</TabsList>
|
|
125
|
+
<TabsContent value="slide" className="mt-0 flex-1">
|
|
126
|
+
<SlideConfigForm
|
|
127
|
+
layout={layout}
|
|
128
|
+
setLayout={setLayout}
|
|
129
|
+
cellId={cellId}
|
|
130
|
+
/>
|
|
131
|
+
</TabsContent>
|
|
132
|
+
<TabsContent value="deck" className="mt-0 flex-1">
|
|
133
|
+
<DeckConfigForm layout={layout} setLayout={setLayout} />
|
|
134
|
+
</TabsContent>
|
|
135
|
+
</Tabs>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const SlideConfigForm = ({
|
|
140
|
+
layout,
|
|
141
|
+
setLayout,
|
|
142
|
+
cellId,
|
|
143
|
+
}: {
|
|
144
|
+
layout: SlidesLayout;
|
|
145
|
+
setLayout: (layout: SlidesLayout) => void;
|
|
146
|
+
cellId: CellId;
|
|
147
|
+
}) => {
|
|
148
|
+
const currentSlideType: SlideType =
|
|
149
|
+
layout.cells.get(cellId)?.type ?? DEFAULT_SLIDE_TYPE;
|
|
150
|
+
|
|
151
|
+
const handleSlideTypeChange = (value: SlideType) => {
|
|
152
|
+
const existingConfig = layout.cells.get(cellId);
|
|
153
|
+
const newCells = new Map(layout.cells);
|
|
154
|
+
newCells.set(cellId, { ...existingConfig, type: value });
|
|
155
|
+
setLayout({
|
|
156
|
+
...layout,
|
|
157
|
+
cells: newCells,
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex flex-col gap-3">
|
|
163
|
+
<span className="font-semibold text-sm">Slide type</span>
|
|
164
|
+
<RadioGroup
|
|
165
|
+
aria-label="Slide type"
|
|
166
|
+
value={currentSlideType}
|
|
167
|
+
onValueChange={(value) => handleSlideTypeChange(value as SlideType)}
|
|
168
|
+
className="flex flex-col gap-1.5"
|
|
169
|
+
>
|
|
170
|
+
{SLIDE_TYPE_OPTIONS.map(({ value, label, description, Icon }) => {
|
|
171
|
+
const isSelected = currentSlideType === value;
|
|
172
|
+
return (
|
|
173
|
+
<RadioGroupItem
|
|
174
|
+
key={value}
|
|
175
|
+
value={value}
|
|
176
|
+
className={cn(
|
|
177
|
+
"group h-auto w-full text-left rounded-md p-2.5 transition-colors shadow-none! border",
|
|
178
|
+
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
179
|
+
isSelected
|
|
180
|
+
? "border-primary bg-primary/5"
|
|
181
|
+
: "border-border bg-background hover:bg-accent/50 hover:border-foreground/30",
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
<div className="flex items-start gap-2.5">
|
|
185
|
+
<span
|
|
186
|
+
className={cn(
|
|
187
|
+
"mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded",
|
|
188
|
+
isSelected
|
|
189
|
+
? "bg-primary/10 text-primary"
|
|
190
|
+
: "bg-muted text-muted-foreground group-hover:text-foreground",
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
<Icon className="h-3.5 w-3.5" />
|
|
194
|
+
</span>
|
|
195
|
+
<div>
|
|
196
|
+
<p
|
|
197
|
+
className={cn(
|
|
198
|
+
"text-sm font-medium leading-tight",
|
|
199
|
+
isSelected ? "text-primary" : "text-foreground",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{label}
|
|
203
|
+
</p>
|
|
204
|
+
<p className="mt-0.5 text-xs text-foreground/70">
|
|
205
|
+
{description}
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</RadioGroupItem>
|
|
210
|
+
);
|
|
211
|
+
})}
|
|
212
|
+
</RadioGroup>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const DeckConfigForm = ({
|
|
218
|
+
layout,
|
|
219
|
+
setLayout,
|
|
220
|
+
}: {
|
|
221
|
+
layout: SlidesLayout;
|
|
222
|
+
setLayout: (layout: SlidesLayout) => void;
|
|
223
|
+
}) => {
|
|
224
|
+
const currentTransition: DeckTransition =
|
|
225
|
+
layout.deck?.transition ?? DEFAULT_DECK_TRANSITION;
|
|
226
|
+
const activeDescription = DECK_TRANSITION_OPTIONS.find(
|
|
227
|
+
(opt) => opt.value === currentTransition,
|
|
228
|
+
)?.description;
|
|
229
|
+
|
|
230
|
+
const handleTransitionChange = (value: DeckTransition) => {
|
|
231
|
+
setLayout({
|
|
232
|
+
...layout,
|
|
233
|
+
deck: { ...layout.deck, transition: value },
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className="flex flex-col gap-3">
|
|
239
|
+
<div className="flex flex-col gap-1.5">
|
|
240
|
+
<label
|
|
241
|
+
htmlFor="deck-transition"
|
|
242
|
+
className="font-semibold text-sm text-foreground"
|
|
243
|
+
>
|
|
244
|
+
Transition
|
|
245
|
+
</label>
|
|
246
|
+
<Select
|
|
247
|
+
value={currentTransition}
|
|
248
|
+
onValueChange={(value) =>
|
|
249
|
+
handleTransitionChange(value as DeckTransition)
|
|
250
|
+
}
|
|
251
|
+
>
|
|
252
|
+
<SelectTrigger id="deck-transition" aria-label="Slide transition">
|
|
253
|
+
<SelectValue />
|
|
254
|
+
</SelectTrigger>
|
|
255
|
+
<SelectContent>
|
|
256
|
+
{DECK_TRANSITION_OPTIONS.map(({ value, label }) => (
|
|
257
|
+
<SelectItem key={value} value={value}>
|
|
258
|
+
{label}
|
|
259
|
+
</SelectItem>
|
|
260
|
+
))}
|
|
261
|
+
</SelectContent>
|
|
262
|
+
</Select>
|
|
263
|
+
{activeDescription && (
|
|
264
|
+
<p className="text-xs text-foreground/70">{activeDescription}</p>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const SlideSidebar = ({
|
|
272
|
+
configWidth,
|
|
273
|
+
layout,
|
|
274
|
+
setLayout,
|
|
275
|
+
activeConfigCell,
|
|
276
|
+
}: {
|
|
277
|
+
configWidth: number;
|
|
278
|
+
layout: SlidesLayout;
|
|
279
|
+
setLayout: (layout: SlidesLayout) => void;
|
|
280
|
+
activeConfigCell?: RuntimeCell;
|
|
281
|
+
}) => {
|
|
282
|
+
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<aside
|
|
286
|
+
className="h-full flex flex-col border-l border-border/60 bg-muted/20 transition-[width] duration-200 ease-out overflow-hidden"
|
|
287
|
+
style={{
|
|
288
|
+
width: isConfigOpen ? configWidth : COLLAPSED_CONFIG_WIDTH,
|
|
289
|
+
}}
|
|
290
|
+
aria-label="Slide configuration"
|
|
291
|
+
>
|
|
292
|
+
<header
|
|
293
|
+
className={cn(
|
|
294
|
+
"flex items-center h-9 shrink-0 border-b border-border/60",
|
|
295
|
+
isConfigOpen ? "justify-between px-2" : "justify-center px-0",
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{isConfigOpen && (
|
|
299
|
+
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground pl-1">
|
|
300
|
+
Configuration
|
|
301
|
+
</span>
|
|
302
|
+
)}
|
|
303
|
+
<Tooltip content={isConfigOpen ? "Collapse panel" : "Expand panel"}>
|
|
304
|
+
<Button
|
|
305
|
+
variant="ghost"
|
|
306
|
+
size="icon"
|
|
307
|
+
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
308
|
+
onClick={() => setIsConfigOpen(!isConfigOpen)}
|
|
309
|
+
aria-expanded={isConfigOpen}
|
|
310
|
+
aria-controls="slide-config-panel"
|
|
311
|
+
>
|
|
312
|
+
{isConfigOpen ? (
|
|
313
|
+
<PanelRightCloseIcon className="h-4 w-4" />
|
|
314
|
+
) : (
|
|
315
|
+
<PanelRightOpenIcon className="h-4 w-4" />
|
|
316
|
+
)}
|
|
317
|
+
</Button>
|
|
318
|
+
</Tooltip>
|
|
319
|
+
</header>
|
|
320
|
+
|
|
321
|
+
{isConfigOpen && (
|
|
322
|
+
<div
|
|
323
|
+
id="slide-config-panel"
|
|
324
|
+
className="flex-1 overflow-y-auto overflow-x-hidden"
|
|
325
|
+
>
|
|
326
|
+
{activeConfigCell ? (
|
|
327
|
+
<SlidesForm
|
|
328
|
+
layout={layout}
|
|
329
|
+
setLayout={setLayout}
|
|
330
|
+
cellId={activeConfigCell.id}
|
|
331
|
+
/>
|
|
332
|
+
) : (
|
|
333
|
+
<div className="flex flex-col gap-1.5 p-3 text-xs text-muted-foreground">
|
|
334
|
+
<span className="font-semibold text-sm text-foreground">
|
|
335
|
+
No slides yet
|
|
336
|
+
</span>
|
|
337
|
+
<p>
|
|
338
|
+
Run a cell that produces output to add it to the deck. Slide
|
|
339
|
+
settings will appear here once a slide is selected.
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</aside>
|
|
346
|
+
);
|
|
347
|
+
};
|
|
@@ -33,9 +33,11 @@ const RadioGroupItem = React.forwardRef<
|
|
|
33
33
|
)}
|
|
34
34
|
{...props}
|
|
35
35
|
>
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
36
|
+
{children ?? (
|
|
37
|
+
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
|
38
|
+
<Circle className="h-[10px] w-[10px] fill-primary text-current" />
|
|
39
|
+
</RadioGroupPrimitive.Indicator>
|
|
40
|
+
)}
|
|
39
41
|
</RadioGroupPrimitive.Item>
|
|
40
42
|
);
|
|
41
43
|
});
|
package/src/core/cells/types.ts
CHANGED
|
@@ -119,6 +119,8 @@ export interface CellRuntimeState {
|
|
|
119
119
|
serialization?: string | null;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export type RuntimeCell = CellRuntimeState & CellData;
|
|
123
|
+
|
|
122
124
|
export type WithResponse<T> = T & {
|
|
123
125
|
/**
|
|
124
126
|
* This is not saved to the server, but we update this field
|
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
} from "@/__tests__/branded";
|
|
11
11
|
|
|
12
12
|
type Base64String = components["schemas"]["Base64String"];
|
|
13
|
+
interface TestIslandApp {
|
|
14
|
+
id: string;
|
|
15
|
+
cells: { code: string; idx: number; output: string }[];
|
|
16
|
+
}
|
|
17
|
+
interface TestExportContext {
|
|
18
|
+
trusted: true;
|
|
19
|
+
notebookCode?: string;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
// Mock browser APIs before any imports
|
|
15
23
|
vi.stubGlobal(
|
|
@@ -33,8 +41,23 @@ class MockURL {
|
|
|
33
41
|
vi.stubGlobal("URL", MockURL);
|
|
34
42
|
|
|
35
43
|
// Mock the worker RPC before importing the bridge
|
|
36
|
-
const
|
|
37
|
-
|
|
44
|
+
const {
|
|
45
|
+
mockBridge,
|
|
46
|
+
mockLoadPackages,
|
|
47
|
+
mockStartSessionRequest,
|
|
48
|
+
mockParseMarimoIslandApps,
|
|
49
|
+
mockCreateMarimoFile,
|
|
50
|
+
mockGetMarimoExportContext,
|
|
51
|
+
} = vi.hoisted(() => ({
|
|
52
|
+
mockBridge: vi.fn(),
|
|
53
|
+
mockLoadPackages: vi.fn(),
|
|
54
|
+
mockStartSessionRequest: vi.fn(),
|
|
55
|
+
mockParseMarimoIslandApps: vi.fn<() => TestIslandApp[]>(() => []),
|
|
56
|
+
mockCreateMarimoFile: vi.fn(),
|
|
57
|
+
mockGetMarimoExportContext: vi.fn<() => TestExportContext | undefined>(
|
|
58
|
+
() => undefined,
|
|
59
|
+
),
|
|
60
|
+
}));
|
|
38
61
|
|
|
39
62
|
vi.mock("@/core/wasm/rpc", () => ({
|
|
40
63
|
getWorkerRPC: () => ({
|
|
@@ -42,7 +65,7 @@ vi.mock("@/core/wasm/rpc", () => ({
|
|
|
42
65
|
request: {
|
|
43
66
|
bridge: mockBridge,
|
|
44
67
|
loadPackages: mockLoadPackages,
|
|
45
|
-
startSession:
|
|
68
|
+
startSession: mockStartSessionRequest,
|
|
46
69
|
},
|
|
47
70
|
send: {
|
|
48
71
|
consumerReady: vi.fn(),
|
|
@@ -54,8 +77,8 @@ vi.mock("@/core/wasm/rpc", () => ({
|
|
|
54
77
|
|
|
55
78
|
// Mock the parse module to avoid DOM dependencies
|
|
56
79
|
vi.mock("../parse", () => ({
|
|
57
|
-
parseMarimoIslandApps:
|
|
58
|
-
createMarimoFile:
|
|
80
|
+
parseMarimoIslandApps: mockParseMarimoIslandApps,
|
|
81
|
+
createMarimoFile: mockCreateMarimoFile,
|
|
59
82
|
}));
|
|
60
83
|
|
|
61
84
|
// Mock uuid to have predictable tokens
|
|
@@ -63,6 +86,10 @@ vi.mock("@/utils/uuid", () => ({
|
|
|
63
86
|
generateUUID: () => "test-uuid-12345",
|
|
64
87
|
}));
|
|
65
88
|
|
|
89
|
+
vi.mock("@/core/static/export-context", () => ({
|
|
90
|
+
getMarimoExportContext: mockGetMarimoExportContext,
|
|
91
|
+
}));
|
|
92
|
+
|
|
66
93
|
// Mock getMarimoVersion
|
|
67
94
|
vi.mock("@/core/meta/globals", () => ({
|
|
68
95
|
getMarimoVersion: () => "0.0.0-test",
|
|
@@ -71,6 +98,7 @@ vi.mock("@/core/meta/globals", () => ({
|
|
|
71
98
|
// Mock the jotai store
|
|
72
99
|
vi.mock("@/core/state/jotai", () => ({
|
|
73
100
|
store: {
|
|
101
|
+
get: vi.fn(),
|
|
74
102
|
set: vi.fn(),
|
|
75
103
|
},
|
|
76
104
|
}));
|
|
@@ -83,9 +111,92 @@ describe("IslandsPyodideBridge", () => {
|
|
|
83
111
|
|
|
84
112
|
beforeEach(() => {
|
|
85
113
|
vi.clearAllMocks();
|
|
114
|
+
mockParseMarimoIslandApps.mockReturnValue([]);
|
|
115
|
+
mockCreateMarimoFile.mockReset();
|
|
116
|
+
mockGetMarimoExportContext.mockReturnValue(undefined);
|
|
86
117
|
bridge = new IslandsPyodideBridge({ autoStartSessions: false });
|
|
87
118
|
});
|
|
88
119
|
|
|
120
|
+
describe("startSessionsForAllApps", () => {
|
|
121
|
+
it("should prefer trusted export notebook code when there is exactly one reactive app", async () => {
|
|
122
|
+
mockParseMarimoIslandApps.mockReturnValue([
|
|
123
|
+
{
|
|
124
|
+
id: "app-1",
|
|
125
|
+
cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
mockGetMarimoExportContext.mockReturnValue({
|
|
129
|
+
trusted: true,
|
|
130
|
+
notebookCode:
|
|
131
|
+
"import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await (
|
|
135
|
+
bridge as unknown as { startSessionsForAllApps(): Promise<void> }
|
|
136
|
+
).startSessionsForAllApps();
|
|
137
|
+
|
|
138
|
+
expect(mockCreateMarimoFile).not.toHaveBeenCalled();
|
|
139
|
+
expect(mockStartSessionRequest).toHaveBeenCalledWith({
|
|
140
|
+
appId: "app-1",
|
|
141
|
+
code: "import marimo\napp = marimo.App()\n@app.cell\ndef __():\n x = 1\n return",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should keep synthesized per-app files for multiple reactive apps even when export context exists", async () => {
|
|
146
|
+
mockParseMarimoIslandApps.mockReturnValue([
|
|
147
|
+
{
|
|
148
|
+
id: "app-1",
|
|
149
|
+
cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "app-2",
|
|
153
|
+
cells: [{ code: "y = 2", idx: 0, output: "<div>2</div>" }],
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
mockGetMarimoExportContext.mockReturnValue({
|
|
157
|
+
trusted: true,
|
|
158
|
+
notebookCode: "full notebook should be ignored",
|
|
159
|
+
});
|
|
160
|
+
mockCreateMarimoFile
|
|
161
|
+
.mockReturnValueOnce("generated app 1")
|
|
162
|
+
.mockReturnValueOnce("generated app 2");
|
|
163
|
+
|
|
164
|
+
await (
|
|
165
|
+
bridge as unknown as { startSessionsForAllApps(): Promise<void> }
|
|
166
|
+
).startSessionsForAllApps();
|
|
167
|
+
|
|
168
|
+
expect(mockCreateMarimoFile).toHaveBeenCalledTimes(2);
|
|
169
|
+
expect(mockStartSessionRequest).toHaveBeenNthCalledWith(1, {
|
|
170
|
+
appId: "app-1",
|
|
171
|
+
code: "generated app 1",
|
|
172
|
+
});
|
|
173
|
+
expect(mockStartSessionRequest).toHaveBeenNthCalledWith(2, {
|
|
174
|
+
appId: "app-2",
|
|
175
|
+
code: "generated app 2",
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should synthesize a file for a single app when no trusted export context is present", async () => {
|
|
180
|
+
mockParseMarimoIslandApps.mockReturnValue([
|
|
181
|
+
{
|
|
182
|
+
id: "app-1",
|
|
183
|
+
cells: [{ code: "x = 1", idx: 0, output: "<div>1</div>" }],
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
mockCreateMarimoFile.mockReturnValue("generated app 1");
|
|
187
|
+
|
|
188
|
+
await (
|
|
189
|
+
bridge as unknown as { startSessionsForAllApps(): Promise<void> }
|
|
190
|
+
).startSessionsForAllApps();
|
|
191
|
+
|
|
192
|
+
expect(mockCreateMarimoFile).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(mockStartSessionRequest).toHaveBeenCalledWith({
|
|
194
|
+
appId: "app-1",
|
|
195
|
+
code: "generated app 1",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
89
200
|
describe("sendComponentValues", () => {
|
|
90
201
|
it("should include type field and token in control request", async () => {
|
|
91
202
|
const request = {
|
|
@@ -10,6 +10,7 @@ import { generateUUID } from "@/utils/uuid";
|
|
|
10
10
|
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
|
|
11
11
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
12
12
|
import { store as defaultStore } from "../state/jotai";
|
|
13
|
+
import { getMarimoExportContext } from "../static/export-context";
|
|
13
14
|
import { createMarimoFile, parseMarimoIslandApps } from "./parse";
|
|
14
15
|
import { islandsInitializedAtom } from "./state";
|
|
15
16
|
import type { WorkerSchema } from "./worker/worker";
|
|
@@ -123,8 +124,11 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
123
124
|
`Starting sessions for ${apps.length} app(s):`,
|
|
124
125
|
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
|
|
125
126
|
);
|
|
127
|
+
const exportContext =
|
|
128
|
+
apps.length === 1 ? getMarimoExportContext() : undefined;
|
|
129
|
+
const notebookCode = exportContext?.notebookCode;
|
|
126
130
|
for (const app of apps) {
|
|
127
|
-
const file = createMarimoFile(app);
|
|
131
|
+
const file = notebookCode || createMarimoFile(app);
|
|
128
132
|
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
129
133
|
this.startSession({
|
|
130
134
|
code: file,
|
|
@@ -84,7 +84,6 @@ export function getSerializedLayout() {
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
const data = layoutData[selectedLayout];
|
|
88
87
|
const plugin = cellRendererPlugins.find(
|
|
89
88
|
(plugin) => plugin.type === selectedLayout,
|
|
90
89
|
);
|
|
@@ -92,8 +91,13 @@ export function getSerializedLayout() {
|
|
|
92
91
|
Logger.error(`Unknown layout type: ${selectedLayout}`);
|
|
93
92
|
return null;
|
|
94
93
|
}
|
|
94
|
+
const cells = notebookCells(notebook);
|
|
95
|
+
// Fall back to the plugin's initial layout when the user has not yet
|
|
96
|
+
// interacted with this layout — otherwise serializers that expect a
|
|
97
|
+
// structured layout object crash on `undefined`.
|
|
98
|
+
const data = layoutData[selectedLayout] ?? plugin.getInitialLayout(cells);
|
|
95
99
|
return {
|
|
96
100
|
type: selectedLayout,
|
|
97
|
-
data: plugin.serializeLayout(data,
|
|
101
|
+
data: plugin.serializeLayout(data, cells),
|
|
98
102
|
};
|
|
99
103
|
}
|