@marimo-team/islands 0.23.3-dev4 → 0.23.3-dev41
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-CTt4WX0V.js → chat-ui-DEd_Ndal.js} +82 -82
- package/dist/{html-to-image-BdsDysfl.js → html-to-image-DBosi5GK.js} +2243 -2217
- package/dist/main.js +1104 -1099
- package/dist/{process-output-COL2Pf5I.js → process-output-k-4WHpxz.js} +1 -1
- package/dist/{reveal-component-Cd5Y35Ny.js → reveal-component-agH2Be6_.js} +2 -2
- package/dist/{slide-BEerfanN.js → slide-CoAyRjHI.js} +693 -574
- package/package.json +2 -2
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +84 -2
- package/src/components/editor/file-tree/file-explorer.tsx +142 -203
- package/src/components/editor/file-tree/file-name-input.tsx +41 -0
- package/src/components/editor/file-tree/file-operations.tsx +266 -0
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/file-tree/requesting-tree.tsx +68 -49
- package/src/components/editor/output/JsonOutput.tsx +157 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +147 -2
- package/src/components/home/state.ts +13 -1
- package/src/components/pages/home-page.tsx +116 -10
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/network/requests-network.ts +0 -3
- 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
- package/src/utils/__tests__/path.test.ts +20 -0
- package/src/utils/pathUtils.test.ts +141 -1
- package/src/utils/pathUtils.ts +46 -0
- package/src/utils/paths.ts +9 -1
|
@@ -39,4 +39,84 @@ describe("JsonOutput with enhanced mimetype handling", () => {
|
|
|
39
39
|
expect(container).toBeInTheDocument();
|
|
40
40
|
expect(container.querySelector(".marimo-json-output")).toBeInTheDocument();
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it("renders encoded non-string keys with Python-style affordances", () => {
|
|
44
|
+
// Server-side `_key_formatter` encodes non-string dict keys with
|
|
45
|
+
// mimetype prefixes; the frontend `keyRenderer` must decode them
|
|
46
|
+
// so users see the original Python types (unquoted ints, parens for
|
|
47
|
+
// tuples, etc.) instead of the raw encoded strings.
|
|
48
|
+
const data = {
|
|
49
|
+
"text/plain+int:2": "int_val",
|
|
50
|
+
"text/plain+float:2.5": "float_val",
|
|
51
|
+
"text/plain+bool:True": "bool_val",
|
|
52
|
+
"text/plain+none:": "none_val",
|
|
53
|
+
"text/plain+tuple:[1, 2]": "tuple_val",
|
|
54
|
+
"text/plain+frozenset:[3, 4]": "fs_val",
|
|
55
|
+
"text/plain+str:text/plain+int:2": "escaped_str_val",
|
|
56
|
+
plain: "unchanged",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
60
|
+
const text = container.textContent ?? "";
|
|
61
|
+
|
|
62
|
+
// `text/plain+str:` is the escape prefix — must never survive in output.
|
|
63
|
+
expect(text).not.toContain("text/plain+str:");
|
|
64
|
+
// Other encoded prefixes must not leak as-is. (They can still appear
|
|
65
|
+
// inside the unescaped original string key `"text/plain+int:2"`,
|
|
66
|
+
// which is intentional — but not for types *other* than int.)
|
|
67
|
+
expect(text).not.toContain("text/plain+bool:True");
|
|
68
|
+
expect(text).not.toContain("text/plain+tuple:[");
|
|
69
|
+
expect(text).not.toContain("text/plain+frozenset:[");
|
|
70
|
+
expect(text).not.toContain("text/plain+none:");
|
|
71
|
+
|
|
72
|
+
// Decoded visual forms are present with Python-style affordances.
|
|
73
|
+
expect(text).toContain('None:"none_val"');
|
|
74
|
+
expect(text).toContain('True:"bool_val"');
|
|
75
|
+
expect(text).toContain('2:"int_val"');
|
|
76
|
+
expect(text).toContain('2.5:"float_val"');
|
|
77
|
+
expect(text).toContain('(1, 2):"tuple_val"');
|
|
78
|
+
expect(text).toContain('frozenset({3, 4}):"fs_val"');
|
|
79
|
+
// Escaped str key renders as the original literal string (quoted).
|
|
80
|
+
expect(text).toContain('"text/plain+int:2":"escaped_str_val"');
|
|
81
|
+
// Plain string key unchanged.
|
|
82
|
+
expect(text).toContain('"plain":"unchanged"');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders 1-tuple and empty-frozenset keys with correct Python syntax", () => {
|
|
86
|
+
// Regressions caught in review: `(1)` is not a tuple (needs `(1,)`),
|
|
87
|
+
// and `frozenset({})` reads like constructing from an empty dict
|
|
88
|
+
// (should be `frozenset()`). Locks in the tree-view rendering so these
|
|
89
|
+
// don't slip back.
|
|
90
|
+
const data = {
|
|
91
|
+
"text/plain+tuple:[42]": "one_tuple",
|
|
92
|
+
"text/plain+tuple:[]": "empty_tuple",
|
|
93
|
+
"text/plain+frozenset:[]": "empty_fs",
|
|
94
|
+
"text/plain+frozenset:[1]": "one_fs",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
98
|
+
const text = container.textContent ?? "";
|
|
99
|
+
|
|
100
|
+
expect(text).toContain('(42,):"one_tuple"'); // trailing comma
|
|
101
|
+
expect(text).toContain('():"empty_tuple"');
|
|
102
|
+
expect(text).toContain('frozenset():"empty_fs"'); // not `frozenset({})`
|
|
103
|
+
expect(text).toContain('frozenset({1}):"one_fs"');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("quotes integer-like string keys to distinguish them from int keys", () => {
|
|
107
|
+
// Without this, `"2"` and the decoded int `2` look identical — the
|
|
108
|
+
// textea viewer drops quotes from integer-like string keys by default.
|
|
109
|
+
const data = {
|
|
110
|
+
"2": "string_two",
|
|
111
|
+
"text/plain+int:2": "int_two",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
115
|
+
const text = container.textContent ?? "";
|
|
116
|
+
|
|
117
|
+
expect(text).toContain('"2":"string_two"'); // quoted
|
|
118
|
+
expect(text).toContain('2:"int_two"'); // unquoted
|
|
119
|
+
// Non-integer string keys still render without our intervention.
|
|
120
|
+
expect(text).not.toContain("text/plain+"); // prefix stripped from int key
|
|
121
|
+
});
|
|
42
122
|
});
|
|
@@ -174,7 +174,21 @@ describe("getCopyValue", () => {
|
|
|
174
174
|
it("should handle sets", () => {
|
|
175
175
|
const value = "text/plain+set:[1,2,3]";
|
|
176
176
|
const result = getCopyValue(value);
|
|
177
|
-
expect(result).toMatchInlineSnapshot(`"{1,2,3}"`);
|
|
177
|
+
expect(result).toMatchInlineSnapshot(`"{1, 2, 3}"`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should handle empty set", () => {
|
|
181
|
+
// Empty set literal in Python is `set()`, not `{}` (which is a dict).
|
|
182
|
+
expect(getCopyValue("text/plain+set:[]")).toMatchInlineSnapshot(`"set()"`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should handle frozenset values", () => {
|
|
186
|
+
expect(getCopyValue("text/plain+frozenset:[1,2]")).toMatchInlineSnapshot(
|
|
187
|
+
`"frozenset({1, 2})"`,
|
|
188
|
+
);
|
|
189
|
+
expect(getCopyValue("text/plain+frozenset:[]")).toMatchInlineSnapshot(
|
|
190
|
+
`"frozenset()"`,
|
|
191
|
+
);
|
|
178
192
|
});
|
|
179
193
|
|
|
180
194
|
it("should handle sets in mixed types", () => {
|
|
@@ -188,7 +202,7 @@ describe("getCopyValue", () => {
|
|
|
188
202
|
`
|
|
189
203
|
"{
|
|
190
204
|
"key1": 42,
|
|
191
|
-
"key2": {1,2,3},
|
|
205
|
+
"key2": {1, 2, 3},
|
|
192
206
|
"key3": True
|
|
193
207
|
}"
|
|
194
208
|
`,
|
|
@@ -311,6 +325,137 @@ describe("determineMaxDisplayLength", () => {
|
|
|
311
325
|
});
|
|
312
326
|
});
|
|
313
327
|
|
|
328
|
+
describe("getCopyValue with encoded non-string keys", () => {
|
|
329
|
+
// Keys are encoded by _key_formatter in
|
|
330
|
+
// marimo/_output/formatters/structures.py. Frontend must round-trip them
|
|
331
|
+
// to Python literals in the copy output.
|
|
332
|
+
|
|
333
|
+
it("decodes int keys unquoted", () => {
|
|
334
|
+
// JS reorders integer-like string keys to the front of object iteration
|
|
335
|
+
// (spec-mandated), so `"2"` appears before `"text/plain+int:2"` here.
|
|
336
|
+
// This is pre-existing and unrelated to the encoding — both entries
|
|
337
|
+
// survive, which is the regression this guards.
|
|
338
|
+
const value = { "text/plain+int:2": "no", "2": "oh" };
|
|
339
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
340
|
+
"{
|
|
341
|
+
"2": "oh",
|
|
342
|
+
2: "no"
|
|
343
|
+
}"
|
|
344
|
+
`);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("decodes large int keys unquoted (no BigInt precision concern)", () => {
|
|
348
|
+
const value = { "text/plain+int:18446744073709551616": "v" };
|
|
349
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
350
|
+
"{
|
|
351
|
+
18446744073709551616: "v"
|
|
352
|
+
}"
|
|
353
|
+
`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("decodes float, bool, None, tuple, frozenset keys", () => {
|
|
357
|
+
const value = {
|
|
358
|
+
"text/plain+float:2.5": "f",
|
|
359
|
+
"text/plain+bool:True": "t",
|
|
360
|
+
"text/plain+bool:False": "b",
|
|
361
|
+
"text/plain+none:": "n",
|
|
362
|
+
"text/plain+tuple:[1, 2]": "tup",
|
|
363
|
+
"text/plain+frozenset:[3, 4]": "fs",
|
|
364
|
+
};
|
|
365
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
366
|
+
"{
|
|
367
|
+
2.5: "f",
|
|
368
|
+
True: "t",
|
|
369
|
+
False: "b",
|
|
370
|
+
None: "n",
|
|
371
|
+
(1, 2): "tup",
|
|
372
|
+
frozenset({3, 4}): "fs"
|
|
373
|
+
}"
|
|
374
|
+
`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("emits 1-element tuple keys with a trailing comma (Python syntax)", () => {
|
|
378
|
+
// `(1)` is just `1` in Python — a 1-tuple needs `(1,)`.
|
|
379
|
+
const value = {
|
|
380
|
+
"text/plain+tuple:[1]": "one",
|
|
381
|
+
"text/plain+tuple:[]": "empty",
|
|
382
|
+
};
|
|
383
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
384
|
+
"{
|
|
385
|
+
(1,): "one",
|
|
386
|
+
(): "empty"
|
|
387
|
+
}"
|
|
388
|
+
`);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("emits empty frozenset keys as `frozenset()` not `frozenset({})`", () => {
|
|
392
|
+
// `frozenset({})` reads like it's constructing from an empty dict.
|
|
393
|
+
const value = {
|
|
394
|
+
"text/plain+frozenset:[]": "empty",
|
|
395
|
+
"text/plain+frozenset:[1]": "single",
|
|
396
|
+
};
|
|
397
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
398
|
+
"{
|
|
399
|
+
frozenset(): "empty",
|
|
400
|
+
frozenset({1}): "single"
|
|
401
|
+
}"
|
|
402
|
+
`);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("decodes NaN/Inf float keys to valid Python literals", () => {
|
|
406
|
+
const value = {
|
|
407
|
+
"text/plain+float:nan": "n",
|
|
408
|
+
"text/plain+float:inf": "p",
|
|
409
|
+
"text/plain+float:-inf": "m",
|
|
410
|
+
};
|
|
411
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
412
|
+
"{
|
|
413
|
+
float('nan'): "n",
|
|
414
|
+
float('inf'): "p",
|
|
415
|
+
-float('inf'): "m"
|
|
416
|
+
}"
|
|
417
|
+
`);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("unescapes string keys that looked encoded", () => {
|
|
421
|
+
const value = {
|
|
422
|
+
"text/plain+str:text/plain+int:2": "hello",
|
|
423
|
+
};
|
|
424
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
425
|
+
"{
|
|
426
|
+
"text/plain+int:2": "hello"
|
|
427
|
+
}"
|
|
428
|
+
`);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("decodes keys at every nesting level", () => {
|
|
432
|
+
const value = {
|
|
433
|
+
outer: {
|
|
434
|
+
"text/plain+int:1": "inner",
|
|
435
|
+
"text/plain+tuple:[2, 3]": "tup",
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
439
|
+
"{
|
|
440
|
+
"outer": {
|
|
441
|
+
1: "inner",
|
|
442
|
+
(2, 3): "tup"
|
|
443
|
+
}
|
|
444
|
+
}"
|
|
445
|
+
`);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("leaves plain string keys untouched", () => {
|
|
449
|
+
const value = { foo: 1, bar: 2 };
|
|
450
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
451
|
+
"{
|
|
452
|
+
"foo": 1,
|
|
453
|
+
"bar": 2
|
|
454
|
+
}"
|
|
455
|
+
`);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
314
459
|
describe("getCopyValue with application/ mimetypes", () => {
|
|
315
460
|
it("should strip application/ mimetype prefix from leaf data", () => {
|
|
316
461
|
expect(getCopyValue("application/json:{data}")).toBe('"{data}"');
|
|
@@ -15,7 +15,19 @@ export const RunningNotebooksContext = React.createContext<{
|
|
|
15
15
|
runningNotebooks: new Map(),
|
|
16
16
|
setRunningNotebooks: Functions.NOOP,
|
|
17
17
|
});
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Context providing the workspace root plus a `refreshWorkspace` hook used by
|
|
21
|
+
* file actions (rename/duplicate/delete) so they can invalidate both the
|
|
22
|
+
* workspace tree and any sibling views (e.g. recent notebooks) in one call.
|
|
23
|
+
*/
|
|
24
|
+
export const WorkspaceContext = React.createContext<{
|
|
25
|
+
root: string;
|
|
26
|
+
refreshWorkspace: () => void;
|
|
27
|
+
}>({
|
|
28
|
+
root: "",
|
|
29
|
+
refreshWorkspace: Functions.NOOP,
|
|
30
|
+
});
|
|
19
31
|
|
|
20
32
|
export const includeMarkdownAtom = atomWithStorage<boolean>(
|
|
21
33
|
"marimo:home:include-markdown",
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
SearchIcon,
|
|
15
15
|
} from "lucide-react";
|
|
16
16
|
import type React from "react";
|
|
17
|
-
import { Suspense, use, useEffect, useRef, useState } from "react";
|
|
17
|
+
import { Suspense, use, useEffect, useMemo, useRef, useState } from "react";
|
|
18
18
|
import {
|
|
19
19
|
type NodeApi,
|
|
20
20
|
type NodeRendererProps,
|
|
@@ -22,16 +22,27 @@ import {
|
|
|
22
22
|
type TreeApi,
|
|
23
23
|
} from "react-arborist";
|
|
24
24
|
import { useLocale } from "react-aria";
|
|
25
|
+
import useEvent from "react-use-event-hook";
|
|
25
26
|
import { MarkdownIcon } from "@/components/editor/cell/code/icons";
|
|
26
27
|
import {
|
|
27
28
|
FILE_ICON as FILE_TYPE_ICONS,
|
|
28
29
|
type FileIconType as FileType,
|
|
29
30
|
guessFileIconType as guessFileType,
|
|
30
31
|
} from "@/components/editor/file-tree/file-icons";
|
|
32
|
+
import { FileNameInput } from "@/components/editor/file-tree/file-name-input";
|
|
33
|
+
import {
|
|
34
|
+
DeleteMenuItem,
|
|
35
|
+
DuplicateMenuItem,
|
|
36
|
+
FileActionsDropdown,
|
|
37
|
+
RenameMenuItem,
|
|
38
|
+
useFileOperations,
|
|
39
|
+
useNotebookFileActions,
|
|
40
|
+
} from "@/components/editor/file-tree/file-operations";
|
|
31
41
|
import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
32
42
|
import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
|
|
33
43
|
import { Button } from "@/components/ui/button";
|
|
34
44
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
45
|
+
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
|
35
46
|
import { Label } from "@/components/ui/label";
|
|
36
47
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
37
48
|
import { toast } from "@/components/ui/use-toast";
|
|
@@ -61,7 +72,7 @@ import {
|
|
|
61
72
|
expandedFoldersAtom,
|
|
62
73
|
includeMarkdownAtom,
|
|
63
74
|
RunningNotebooksContext,
|
|
64
|
-
|
|
75
|
+
WorkspaceContext,
|
|
65
76
|
} from "../home/state";
|
|
66
77
|
import { Spinner } from "../icons/spinner";
|
|
67
78
|
import { Input } from "../ui/input";
|
|
@@ -131,7 +142,7 @@ const HomePage: React.FC = () => {
|
|
|
131
142
|
files={recents.files}
|
|
132
143
|
/>
|
|
133
144
|
<ErrorBoundary>
|
|
134
|
-
<WorkspaceNotebooks />
|
|
145
|
+
<WorkspaceNotebooks onRefreshRecents={recentsResponse.refetch} />
|
|
135
146
|
</ErrorBoundary>
|
|
136
147
|
</div>
|
|
137
148
|
</RunningNotebooksContext>
|
|
@@ -139,7 +150,9 @@ const HomePage: React.FC = () => {
|
|
|
139
150
|
);
|
|
140
151
|
};
|
|
141
152
|
|
|
142
|
-
const WorkspaceNotebooks: React.FC
|
|
153
|
+
const WorkspaceNotebooks: React.FC<{ onRefreshRecents: () => void }> = ({
|
|
154
|
+
onRefreshRecents,
|
|
155
|
+
}) => {
|
|
143
156
|
const { getWorkspaceFiles } = useRequestClient();
|
|
144
157
|
const [includeMarkdown, setIncludeMarkdown] = useAtom(includeMarkdownAtom);
|
|
145
158
|
const [searchText, setSearchText] = useState("");
|
|
@@ -154,6 +167,19 @@ const WorkspaceNotebooks: React.FC = () => {
|
|
|
154
167
|
[includeMarkdown],
|
|
155
168
|
);
|
|
156
169
|
|
|
170
|
+
// Fire-and-forget refresh of both the workspace tree and the "Recent
|
|
171
|
+
// notebooks" list — file mutations on the workspace tree can affect both,
|
|
172
|
+
// so we invalidate them together rather than having two refresh triggers.
|
|
173
|
+
const refreshWorkspace = useEvent(() => {
|
|
174
|
+
refetch();
|
|
175
|
+
onRefreshRecents();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const workspaceContextValue = useMemo(
|
|
179
|
+
() => ({ root: workspace?.root ?? "", refreshWorkspace }),
|
|
180
|
+
[workspace?.root, refreshWorkspace],
|
|
181
|
+
);
|
|
182
|
+
|
|
157
183
|
if (isPending) {
|
|
158
184
|
return <Spinner centered={true} size="xlarge" className="mt-6" />;
|
|
159
185
|
}
|
|
@@ -167,7 +193,7 @@ const WorkspaceNotebooks: React.FC = () => {
|
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
return (
|
|
170
|
-
<
|
|
196
|
+
<WorkspaceContext value={workspaceContextValue}>
|
|
171
197
|
<div className="flex flex-col gap-2">
|
|
172
198
|
{workspace.hasMore && (
|
|
173
199
|
<Banner kind="warn" className="rounded p-4">
|
|
@@ -216,7 +242,7 @@ const WorkspaceNotebooks: React.FC = () => {
|
|
|
216
242
|
<NotebookFileTree searchText={searchText} files={workspace.files} />
|
|
217
243
|
</div>
|
|
218
244
|
</div>
|
|
219
|
-
</
|
|
245
|
+
</WorkspaceContext>
|
|
220
246
|
);
|
|
221
247
|
};
|
|
222
248
|
|
|
@@ -244,6 +270,8 @@ const NotebookFileTree: React.FC<{
|
|
|
244
270
|
const [openState, setOpenState] = useAtom(expandedFoldersAtom);
|
|
245
271
|
const openStateIsEmpty = Object.keys(openState).length === 0;
|
|
246
272
|
const ref = useRef<TreeApi<FileInfo>>(undefined);
|
|
273
|
+
const { root, refreshWorkspace } = use(WorkspaceContext);
|
|
274
|
+
const { renameFile } = useFileOperations({ root });
|
|
247
275
|
|
|
248
276
|
useEffect(() => {
|
|
249
277
|
// If empty, collapse all
|
|
@@ -252,6 +280,21 @@ const NotebookFileTree: React.FC<{
|
|
|
252
280
|
}
|
|
253
281
|
}, [openStateIsEmpty]);
|
|
254
282
|
|
|
283
|
+
const handleRename = useEvent(async (id: string, name: string) => {
|
|
284
|
+
const node = ref.current?.get(id);
|
|
285
|
+
if (!node) {
|
|
286
|
+
toast({
|
|
287
|
+
title: "Failed",
|
|
288
|
+
description: `Node with id ${id} not found in the tree`,
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const result = await renameFile(node.data, name);
|
|
293
|
+
if (result) {
|
|
294
|
+
refreshWorkspace();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
255
298
|
if (files.length === 0) {
|
|
256
299
|
return (
|
|
257
300
|
<div className="flex flex-col px-5 py-10 items-center justify-center">
|
|
@@ -277,6 +320,9 @@ const NotebookFileTree: React.FC<{
|
|
|
277
320
|
const prevOpen = openState[id] ?? false;
|
|
278
321
|
setOpenState({ ...openState, [id]: !prevOpen });
|
|
279
322
|
}}
|
|
323
|
+
onRename={async ({ id, name }) => {
|
|
324
|
+
await handleRename(id, name);
|
|
325
|
+
}}
|
|
280
326
|
padding={5}
|
|
281
327
|
rowHeight={35}
|
|
282
328
|
indent={15}
|
|
@@ -286,7 +332,6 @@ const NotebookFileTree: React.FC<{
|
|
|
286
332
|
// Disable interactions
|
|
287
333
|
disableDrop={true}
|
|
288
334
|
disableDrag={true}
|
|
289
|
-
disableEdit={true}
|
|
290
335
|
disableMultiSelection={true}
|
|
291
336
|
>
|
|
292
337
|
{Node}
|
|
@@ -301,11 +346,24 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
|
|
|
301
346
|
|
|
302
347
|
const Icon = FILE_TYPE_ICONS[fileType];
|
|
303
348
|
const iconEl = <Icon className="w-5 h-5 shrink-0" strokeWidth={1.5} />;
|
|
304
|
-
const root = use(
|
|
349
|
+
const { root } = use(WorkspaceContext);
|
|
350
|
+
const { runningNotebooks } = use(RunningNotebooksContext);
|
|
305
351
|
|
|
306
352
|
const renderItem = () => {
|
|
307
353
|
const itemClassName =
|
|
308
354
|
"flex items-center pl-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l flex-1 overflow-hidden h-full pr-3 gap-2";
|
|
355
|
+
|
|
356
|
+
// Inline rename input; react-arborist flips `node.isEditing` when
|
|
357
|
+
// `node.edit()` is called from the FileActions menu.
|
|
358
|
+
if (node.isEditing) {
|
|
359
|
+
return (
|
|
360
|
+
<div className={itemClassName}>
|
|
361
|
+
{iconEl}
|
|
362
|
+
<FileNameInput node={node} />
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
309
367
|
if (node.data.isDirectory) {
|
|
310
368
|
return (
|
|
311
369
|
<span className={itemClassName}>
|
|
@@ -322,6 +380,7 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
|
|
|
322
380
|
|
|
323
381
|
const isMarkdown =
|
|
324
382
|
relativePath.endsWith(".md") || relativePath.endsWith(".qmd");
|
|
383
|
+
const isRunning = runningNotebooks.has(relativePath);
|
|
325
384
|
|
|
326
385
|
return (
|
|
327
386
|
<a
|
|
@@ -334,10 +393,19 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
|
|
|
334
393
|
{node.data.name}
|
|
335
394
|
{isMarkdown && <MarkdownIcon className="ml-2 inline opacity-80" />}
|
|
336
395
|
</span>
|
|
337
|
-
|
|
396
|
+
|
|
397
|
+
<FileActions node={node} isRunning={isRunning} />
|
|
398
|
+
{/*
|
|
399
|
+
Trailing action slots. Using a fixed-width row here (rather than
|
|
400
|
+
conditionally rendered inline elements) keeps every row's right
|
|
401
|
+
edge aligned even though any individual slot may be empty.
|
|
402
|
+
*/}
|
|
403
|
+
<div className="w-8 h-8 flex items-center justify-center shrink-0">
|
|
404
|
+
<SessionShutdownButton filePath={relativePath} />
|
|
405
|
+
</div>
|
|
338
406
|
<ExternalLinkIcon
|
|
339
407
|
size={20}
|
|
340
|
-
className="group-hover:opacity-100 opacity-0 text-primary"
|
|
408
|
+
className="group-hover:opacity-100 opacity-0 text-primary shrink-0"
|
|
341
409
|
/>
|
|
342
410
|
</a>
|
|
343
411
|
);
|
|
@@ -362,6 +430,44 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
|
|
|
362
430
|
);
|
|
363
431
|
};
|
|
364
432
|
|
|
433
|
+
const FileActions = ({
|
|
434
|
+
node,
|
|
435
|
+
isRunning,
|
|
436
|
+
}: {
|
|
437
|
+
node: NodeApi<FileInfo>;
|
|
438
|
+
isRunning: boolean;
|
|
439
|
+
}) => {
|
|
440
|
+
const { root, refreshWorkspace } = use(WorkspaceContext);
|
|
441
|
+
const { handleRename, handleDuplicate, handleDelete } =
|
|
442
|
+
useNotebookFileActions({ node, root, onAfterChange: refreshWorkspace });
|
|
443
|
+
|
|
444
|
+
const lockedReason = isRunning
|
|
445
|
+
? "Stop the notebook's kernel before renaming or deleting."
|
|
446
|
+
: undefined;
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<FileActionsDropdown
|
|
450
|
+
testId="workspace-more-button"
|
|
451
|
+
buttonClassName="w-8 h-8 p-0 shrink-0"
|
|
452
|
+
contentClassName="print:hidden w-fit min-w-[140px]"
|
|
453
|
+
preventDefaultOnTrigger={true}
|
|
454
|
+
>
|
|
455
|
+
<RenameMenuItem
|
|
456
|
+
onSelect={handleRename}
|
|
457
|
+
disabled={isRunning}
|
|
458
|
+
title={lockedReason}
|
|
459
|
+
/>
|
|
460
|
+
<DuplicateMenuItem onSelect={handleDuplicate} />
|
|
461
|
+
<DropdownMenuSeparator />
|
|
462
|
+
<DeleteMenuItem
|
|
463
|
+
onSelect={handleDelete}
|
|
464
|
+
disabled={isRunning}
|
|
465
|
+
title={lockedReason}
|
|
466
|
+
/>
|
|
467
|
+
</FileActionsDropdown>
|
|
468
|
+
);
|
|
469
|
+
};
|
|
470
|
+
|
|
365
471
|
const FolderArrow = ({ node }: { node: NodeApi<FileInfo> }) => {
|
|
366
472
|
if (!node.data.isDirectory) {
|
|
367
473
|
return <span className="w-5 h-5 shrink-0" />;
|
|
@@ -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 = {
|