@marimo-team/islands 0.24.3-dev36 → 0.24.3-dev39
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/{common-CWnP5Rod.js → common-eNTtBSo2.js} +2 -2
- package/dist/main.js +6 -2
- package/dist/{reveal-component-SdzpGkJs.js → reveal-component-CNqmC7b6.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +7 -0
- package/src/components/editor/alerts/connecting-alert.tsx +1 -66
- package/src/components/editor/alerts/connection-notice.tsx +140 -0
- package/src/components/editor/app-container.tsx +9 -3
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +114 -7
- package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +165 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +93 -166
- package/src/components/editor/chrome/panels/sandbox-controller.tsx +106 -0
- package/src/components/editor/chrome/panels/sandbox-panel.tsx +173 -0
- package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +3 -1
- package/src/components/editor/header/__tests__/status.test.tsx +8 -20
- package/src/components/editor/header/status.tsx +5 -3
- package/src/components/editor/renderers/cell-array.tsx +1 -5
- package/src/core/MarimoApp.tsx +0 -2
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/state.ts +1 -2
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/islands/bridge.ts +3 -0
- package/src/core/network/connection-notice.ts +84 -0
- package/src/core/network/requests-lazy.ts +3 -0
- package/src/core/network/requests-network.ts +12 -0
- package/src/core/network/requests-static.ts +3 -0
- package/src/core/network/requests-toasting.tsx +3 -0
- package/src/core/network/resolve.ts +2 -1
- package/src/core/network/types.ts +7 -0
- package/src/core/packages/__tests__/usePackageAction.test.tsx +129 -0
- package/src/core/packages/__tests__/useSandboxController.test.tsx +165 -0
- package/src/core/packages/package-data.ts +35 -0
- package/src/core/packages/sandbox-state.ts +15 -0
- package/src/core/packages/toast-components.tsx +11 -0
- package/src/core/packages/useInstallPackage.ts +1 -1
- package/src/core/packages/usePackageAction.ts +51 -0
- package/src/core/packages/usePackageDependencies.ts +22 -0
- package/src/core/packages/useSandboxController.ts +188 -0
- package/src/core/run-app.tsx +2 -0
- package/src/core/wasm/bridge.ts +11 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +212 -2
- package/src/core/websocket/types.ts +11 -1
- package/src/core/websocket/useMarimoKernelConnection.tsx +43 -8
- package/src/stories/sandbox.stories.tsx +308 -0
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
HelpCircleIcon,
|
|
8
8
|
} from "lucide-react";
|
|
9
9
|
import React from "react";
|
|
10
|
+
import { connectionNoticeAtom } from "@/core/network/connection-notice";
|
|
10
11
|
import { useOpenSettingsToTab } from "@/components/app-config/state";
|
|
11
12
|
import { Spinner } from "@/components/icons/spinner";
|
|
12
13
|
import { SearchInput } from "@/components/ui/input";
|
|
@@ -20,25 +21,22 @@ import {
|
|
|
20
21
|
} from "@/components/ui/table";
|
|
21
22
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
22
23
|
import { toast } from "@/components/ui/use-toast";
|
|
23
|
-
import {
|
|
24
|
-
import { useRequestClient } from "@/core/network/requests";
|
|
24
|
+
import { isConnectedAtom } from "@/core/network/connection";
|
|
25
25
|
import type {
|
|
26
26
|
DependencyTreeNode,
|
|
27
27
|
DependencyTreeResponse,
|
|
28
28
|
} from "@/core/network/types";
|
|
29
|
+
import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
|
|
29
30
|
import { stripPackageManagerPrefix } from "@/core/packages/package-input-utils";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
showRemovePackageToast,
|
|
33
|
-
showUpgradePackageToast,
|
|
34
|
-
} from "@/core/packages/toast-components";
|
|
31
|
+
import { usePackageAction } from "@/core/packages/usePackageAction";
|
|
32
|
+
import { usePackageDependencies } from "@/core/packages/usePackageDependencies";
|
|
35
33
|
import { useInstallPackages } from "@/core/packages/useInstallPackage";
|
|
36
34
|
import { isWasm } from "@/core/wasm/utils";
|
|
37
|
-
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
38
35
|
import { ErrorBanner } from "@/plugins/impl/common/error-banner";
|
|
39
36
|
import { cn } from "@/utils/cn";
|
|
40
37
|
import { copyToClipboard } from "@/utils/copy";
|
|
41
38
|
import { Events } from "@/utils/events";
|
|
39
|
+
import { SandboxFooter, SandboxStartupPanel } from "./sandbox-panel";
|
|
42
40
|
import { PanelEmptyState } from "./empty-state";
|
|
43
41
|
import { PACKAGES_INPUT_ID, packagesToInstallAtom } from "./packages-utils";
|
|
44
42
|
|
|
@@ -71,36 +69,44 @@ const PackageActionButton: React.FC<{
|
|
|
71
69
|
};
|
|
72
70
|
|
|
73
71
|
const PackagesPanel: React.FC = () => {
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
72
|
+
const sandbox = useAtomValue(sandboxAtom);
|
|
73
|
+
const connected = useAtomValue(isConnectedAtom);
|
|
74
|
+
const notice = useAtomValue(connectionNoticeAtom);
|
|
75
|
+
const operation = useAtomValue(sandboxSyncAtom);
|
|
76
|
+
if (sandbox?.backend) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
79
|
+
{notice && (!operation.pending || !connected) ? (
|
|
80
|
+
<SandboxStartupPanel />
|
|
81
|
+
) : (
|
|
82
|
+
<PackageContents />
|
|
83
|
+
)}
|
|
84
|
+
<SandboxFooter />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (notice) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="p-4 text-sm text-muted-foreground">
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
{notice.pending && <Spinner className="size-3" />}
|
|
93
|
+
{notice.title}
|
|
94
|
+
</div>
|
|
95
|
+
{notice.error && (
|
|
96
|
+
<pre className="mt-3 text-xs whitespace-pre-wrap break-words max-h-52 overflow-auto">
|
|
97
|
+
{notice.error}
|
|
98
|
+
</pre>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return <PackageContents />;
|
|
104
|
+
};
|
|
77
105
|
|
|
106
|
+
const PackageContents: React.FC = () => {
|
|
107
|
+
const { pending: syncing } = useAtomValue(sandboxSyncAtom);
|
|
78
108
|
const [userViewMode, setUserViewMode] = React.useState<ViewMode | null>(null);
|
|
79
|
-
const {
|
|
80
|
-
data: dependencies,
|
|
81
|
-
error,
|
|
82
|
-
refetch,
|
|
83
|
-
isPending,
|
|
84
|
-
} = useAsyncData(async () => {
|
|
85
|
-
// A sandbox's list and tree both inspect the same environment. Wait for
|
|
86
|
-
// the context before issuing the list request so sandboxes do that work
|
|
87
|
-
// only once; non-sandbox managers still need both views.
|
|
88
|
-
const dependencyTreeResponse = await getDependencyTree();
|
|
89
|
-
if (dependencyTreeResponse.context.kind === "sandbox") {
|
|
90
|
-
return {
|
|
91
|
-
list: [],
|
|
92
|
-
context: dependencyTreeResponse.context,
|
|
93
|
-
tree: dependencyTreeResponse.tree,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const listPackagesResponse = await getPackageList();
|
|
98
|
-
return {
|
|
99
|
-
list: listPackagesResponse.packages,
|
|
100
|
-
context: dependencyTreeResponse.context,
|
|
101
|
-
tree: dependencyTreeResponse.tree,
|
|
102
|
-
};
|
|
103
|
-
}, [packageManager]);
|
|
109
|
+
const { data: dependencies, error, isPending } = usePackageDependencies();
|
|
104
110
|
|
|
105
111
|
// Only show on the first load
|
|
106
112
|
if (isPending) {
|
|
@@ -122,58 +128,49 @@ const PackagesPanel: React.FC = () => {
|
|
|
122
128
|
const viewMode = isSandbox
|
|
123
129
|
? "tree"
|
|
124
130
|
: resolveViewMode(userViewMode, isTreeSupported);
|
|
125
|
-
const scopeLabel =
|
|
126
|
-
? `${sandboxBackend} sandbox`
|
|
127
|
-
: name && name !== "<root>"
|
|
128
|
-
? "project"
|
|
129
|
-
: "environment";
|
|
130
|
-
const scopeTitle = sandboxBackend
|
|
131
|
-
? `Dependencies are managed by the ${sandboxBackend} sandbox selected when marimo started.`
|
|
132
|
-
: scopeLabel;
|
|
131
|
+
const scopeLabel = name && name !== "<root>" ? "project" : "environment";
|
|
133
132
|
|
|
134
133
|
return (
|
|
135
134
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
136
|
-
<
|
|
137
|
-
|
|
135
|
+
<fieldset disabled={syncing} className="contents">
|
|
136
|
+
<InstallPackageForm context={dependencies.context} />
|
|
137
|
+
</fieldset>
|
|
138
|
+
{isTreeSupported && !isSandbox && (
|
|
138
139
|
<div className="flex items-center justify-between px-2 py-1 border-b">
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
</div>
|
|
166
|
-
) : (
|
|
167
|
-
<div />
|
|
168
|
-
)}
|
|
140
|
+
<div className="flex gap-1">
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
className={cn(
|
|
144
|
+
"px-2 py-1 text-xs rounded",
|
|
145
|
+
viewMode === "list"
|
|
146
|
+
? "bg-accent text-accent-foreground"
|
|
147
|
+
: "text-muted-foreground hover:text-foreground",
|
|
148
|
+
)}
|
|
149
|
+
onClick={() => setUserViewMode("list")}
|
|
150
|
+
>
|
|
151
|
+
List
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className={cn(
|
|
156
|
+
"px-2 py-1 text-xs rounded",
|
|
157
|
+
viewMode === "tree"
|
|
158
|
+
? "bg-accent text-accent-foreground"
|
|
159
|
+
: "text-muted-foreground hover:text-foreground",
|
|
160
|
+
)}
|
|
161
|
+
onClick={() => setUserViewMode("tree")}
|
|
162
|
+
>
|
|
163
|
+
Tree
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
169
166
|
<div className="flex items-center gap-2">
|
|
170
167
|
<div
|
|
171
168
|
className="items-center border px-2 py-0.5 text-xs transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 text-foreground rounded-sm text-ellipsis block overflow-hidden max-w-fit font-medium"
|
|
172
|
-
title={
|
|
169
|
+
title={scopeLabel}
|
|
173
170
|
>
|
|
174
171
|
{scopeLabel}
|
|
175
172
|
</div>
|
|
176
|
-
{name &&
|
|
173
|
+
{name && (
|
|
177
174
|
<span className="text-xs text-muted-foreground">
|
|
178
175
|
{name}
|
|
179
176
|
{version && ` v${version}`}
|
|
@@ -183,13 +180,12 @@ const PackagesPanel: React.FC = () => {
|
|
|
183
180
|
</div>
|
|
184
181
|
)}
|
|
185
182
|
{viewMode === "list" ? (
|
|
186
|
-
<PackagesList packages={dependencies.list}
|
|
183
|
+
<PackagesList packages={dependencies.list} />
|
|
187
184
|
) : (
|
|
188
185
|
<DependencyTree
|
|
189
186
|
tree={dependencies.tree}
|
|
190
187
|
error={error}
|
|
191
188
|
sandboxBackend={sandboxBackend}
|
|
192
|
-
onSuccess={refetch}
|
|
193
189
|
/>
|
|
194
190
|
)}
|
|
195
191
|
</div>
|
|
@@ -200,8 +196,7 @@ export default PackagesPanel;
|
|
|
200
196
|
|
|
201
197
|
const InstallPackageForm: React.FC<{
|
|
202
198
|
context: PackageInstallationContext;
|
|
203
|
-
|
|
204
|
-
}> = ({ onSuccess, context }) => {
|
|
199
|
+
}> = ({ context }) => {
|
|
205
200
|
const [input, setInput] = React.useState("");
|
|
206
201
|
const { handleClick: openSettings } = useOpenSettingsToTab();
|
|
207
202
|
const isSandbox = context.kind === "sandbox";
|
|
@@ -222,7 +217,6 @@ const InstallPackageForm: React.FC<{
|
|
|
222
217
|
|
|
223
218
|
const { loading, handleInstallPackages } = useInstallPackages();
|
|
224
219
|
const onSuccessInstallPackages = () => {
|
|
225
|
-
onSuccess();
|
|
226
220
|
setInput("");
|
|
227
221
|
};
|
|
228
222
|
|
|
@@ -363,9 +357,8 @@ const InstallPackageForm: React.FC<{
|
|
|
363
357
|
};
|
|
364
358
|
|
|
365
359
|
const PackagesList: React.FC<{
|
|
366
|
-
onSuccess: () => void;
|
|
367
360
|
packages: { name: string; version: string }[];
|
|
368
|
-
}> = ({
|
|
361
|
+
}> = ({ packages }) => {
|
|
369
362
|
// Sort case-insensitively so packages are strictly alphabetical
|
|
370
363
|
// regardless of capitalization (package managers sort inconsistently).
|
|
371
364
|
const sortedPackages = React.useMemo(
|
|
@@ -410,8 +403,8 @@ const PackagesList: React.FC<{
|
|
|
410
403
|
<TableCell>{item.name}</TableCell>
|
|
411
404
|
<TableCell>{item.version}</TableCell>
|
|
412
405
|
<TableCell className="flex justify-end">
|
|
413
|
-
<UpgradeButton packageName={item.name}
|
|
414
|
-
<RemoveButton packageName={item.name}
|
|
406
|
+
<UpgradeButton packageName={item.name} />
|
|
407
|
+
<RemoveButton packageName={item.name} />
|
|
415
408
|
</TableCell>
|
|
416
409
|
</TableRow>
|
|
417
410
|
))}
|
|
@@ -423,40 +416,14 @@ const PackagesList: React.FC<{
|
|
|
423
416
|
const UpgradeButton: React.FC<{
|
|
424
417
|
packageName: string;
|
|
425
418
|
tags?: { kind: string; value: string }[];
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
const [loading, setLoading] = React.useState(false);
|
|
429
|
-
const { addPackage } = useRequestClient();
|
|
430
|
-
|
|
431
|
-
// Hide upgrade button in WASM
|
|
419
|
+
}> = ({ packageName, tags }) => {
|
|
420
|
+
const { loading, run } = usePackageAction("upgrade", packageName, tags);
|
|
432
421
|
if (isWasm()) {
|
|
433
422
|
return null;
|
|
434
423
|
}
|
|
435
424
|
|
|
436
|
-
const handleUpgradePackage = async () => {
|
|
437
|
-
try {
|
|
438
|
-
setLoading(true);
|
|
439
|
-
const group = tags?.find((tag) => tag.kind === "group")?.value;
|
|
440
|
-
const response = await addPackage({
|
|
441
|
-
package: packageName,
|
|
442
|
-
upgrade: true,
|
|
443
|
-
group,
|
|
444
|
-
});
|
|
445
|
-
if (response.restartRequired) {
|
|
446
|
-
showPackageRestartToast();
|
|
447
|
-
} else if (response.success) {
|
|
448
|
-
onSuccess();
|
|
449
|
-
showUpgradePackageToast(packageName);
|
|
450
|
-
} else {
|
|
451
|
-
showUpgradePackageToast(packageName, response.error);
|
|
452
|
-
}
|
|
453
|
-
} finally {
|
|
454
|
-
setLoading(false);
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
|
|
458
425
|
return (
|
|
459
|
-
<PackageActionButton onClick={
|
|
426
|
+
<PackageActionButton onClick={run} loading={loading}>
|
|
460
427
|
Upgrade
|
|
461
428
|
</PackageActionButton>
|
|
462
429
|
);
|
|
@@ -465,34 +432,14 @@ const UpgradeButton: React.FC<{
|
|
|
465
432
|
const RemoveButton: React.FC<{
|
|
466
433
|
packageName: string;
|
|
467
434
|
tags?: { kind: string; value: string }[];
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const handleRemovePackage = async () => {
|
|
474
|
-
try {
|
|
475
|
-
setLoading(true);
|
|
476
|
-
const group = tags?.find((tag) => tag.kind === "group")?.value;
|
|
477
|
-
const response = await removePackage({
|
|
478
|
-
package: packageName,
|
|
479
|
-
group,
|
|
480
|
-
});
|
|
481
|
-
if (response.restartRequired) {
|
|
482
|
-
showPackageRestartToast();
|
|
483
|
-
} else if (response.success) {
|
|
484
|
-
onSuccess();
|
|
485
|
-
showRemovePackageToast(packageName);
|
|
486
|
-
} else {
|
|
487
|
-
showRemovePackageToast(packageName, response.error);
|
|
488
|
-
}
|
|
489
|
-
} finally {
|
|
490
|
-
setLoading(false);
|
|
491
|
-
}
|
|
492
|
-
};
|
|
435
|
+
}> = ({ packageName, tags }) => {
|
|
436
|
+
const { loading, run } = usePackageAction("remove", packageName, tags);
|
|
437
|
+
if (packageName.toLowerCase() === "marimo") {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
493
440
|
|
|
494
441
|
return (
|
|
495
|
-
<PackageActionButton onClick={
|
|
442
|
+
<PackageActionButton onClick={run} loading={loading}>
|
|
496
443
|
Remove
|
|
497
444
|
</PackageActionButton>
|
|
498
445
|
);
|
|
@@ -502,8 +449,7 @@ const DependencyTree: React.FC<{
|
|
|
502
449
|
tree: DependencyTreeNode | null;
|
|
503
450
|
error?: Error | null;
|
|
504
451
|
sandboxBackend: "uv" | "pixi" | null;
|
|
505
|
-
|
|
506
|
-
}> = ({ tree, error, sandboxBackend, onSuccess }) => {
|
|
452
|
+
}> = ({ tree, error, sandboxBackend }) => {
|
|
507
453
|
const [expandedNodes, setExpandedNodes] = React.useState<Set<string>>(
|
|
508
454
|
new Set(),
|
|
509
455
|
);
|
|
@@ -570,7 +516,6 @@ const DependencyTree: React.FC<{
|
|
|
570
516
|
isTopLevel={true}
|
|
571
517
|
expandedNodes={expandedNodes}
|
|
572
518
|
onToggle={toggleNode}
|
|
573
|
-
onSuccess={onSuccess}
|
|
574
519
|
/>
|
|
575
520
|
</div>
|
|
576
521
|
))}
|
|
@@ -586,16 +531,7 @@ const DependencyTreeNode: React.FC<{
|
|
|
586
531
|
isTopLevel?: boolean;
|
|
587
532
|
expandedNodes: Set<string>;
|
|
588
533
|
onToggle: (nodeId: string) => void;
|
|
589
|
-
|
|
590
|
-
}> = ({
|
|
591
|
-
nodeId,
|
|
592
|
-
node,
|
|
593
|
-
level,
|
|
594
|
-
isTopLevel = false,
|
|
595
|
-
expandedNodes,
|
|
596
|
-
onToggle,
|
|
597
|
-
onSuccess,
|
|
598
|
-
}) => {
|
|
534
|
+
}> = ({ nodeId, node, level, isTopLevel = false, expandedNodes, onToggle }) => {
|
|
599
535
|
const hasChildren = node.dependencies.length > 0;
|
|
600
536
|
const isExpanded = expandedNodes.has(nodeId);
|
|
601
537
|
const indent = isTopLevel ? 0 : 16 + level * 16; // Top-level uses CSS padding, children use calculated indent
|
|
@@ -709,17 +645,9 @@ const DependencyTreeNode: React.FC<{
|
|
|
709
645
|
{/* Actions for top-level packages */}
|
|
710
646
|
{isTopLevel && (
|
|
711
647
|
<div className="flex gap-1 invisible group-hover:visible">
|
|
712
|
-
<UpgradeButton
|
|
713
|
-
packageName={node.name}
|
|
714
|
-
tags={node.tags}
|
|
715
|
-
onSuccess={onSuccess}
|
|
716
|
-
/>
|
|
648
|
+
<UpgradeButton packageName={node.name} tags={node.tags} />
|
|
717
649
|
|
|
718
|
-
<RemoveButton
|
|
719
|
-
packageName={node.name}
|
|
720
|
-
tags={node.tags}
|
|
721
|
-
onSuccess={onSuccess}
|
|
722
|
-
/>
|
|
650
|
+
<RemoveButton packageName={node.name} tags={node.tags} />
|
|
723
651
|
</div>
|
|
724
652
|
)}
|
|
725
653
|
</div>
|
|
@@ -736,7 +664,6 @@ const DependencyTreeNode: React.FC<{
|
|
|
736
664
|
isTopLevel={false}
|
|
737
665
|
expandedNodes={expandedNodes}
|
|
738
666
|
onToggle={onToggle}
|
|
739
|
-
onSuccess={onSuccess}
|
|
740
667
|
/>
|
|
741
668
|
))}
|
|
742
669
|
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { Spinner } from "@/components/icons/spinner";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { useSandboxController } from "@/core/packages/useSandboxController";
|
|
14
|
+
import { LazyAnyLanguageCodeMirror } from "@/plugins/impl/code/LazyAnyLanguageCodeMirror";
|
|
15
|
+
import { useTheme } from "@/theme/useTheme";
|
|
16
|
+
import { Paths } from "@/utils/paths";
|
|
17
|
+
import { SandboxErrorOutput } from "./sandbox-panel";
|
|
18
|
+
|
|
19
|
+
export function SandboxController({
|
|
20
|
+
onReconnect,
|
|
21
|
+
}: {
|
|
22
|
+
onReconnect: () => Promise<void>;
|
|
23
|
+
}) {
|
|
24
|
+
const {
|
|
25
|
+
filename,
|
|
26
|
+
open,
|
|
27
|
+
setOpen,
|
|
28
|
+
draft,
|
|
29
|
+
setDraft,
|
|
30
|
+
dirty,
|
|
31
|
+
loading,
|
|
32
|
+
saving,
|
|
33
|
+
pending,
|
|
34
|
+
error,
|
|
35
|
+
diagnostic,
|
|
36
|
+
conflict,
|
|
37
|
+
loadManifest,
|
|
38
|
+
saveAndSync,
|
|
39
|
+
} = useSandboxController(onReconnect);
|
|
40
|
+
const { theme } = useTheme();
|
|
41
|
+
return (
|
|
42
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
43
|
+
<DialogContent className="sm:max-w-xl grid-cols-1 max-h-[80vh] overflow-auto">
|
|
44
|
+
<DialogHeader>
|
|
45
|
+
<DialogTitle>Edit manifest</DialogTitle>
|
|
46
|
+
<DialogDescription className="font-mono text-xs">
|
|
47
|
+
{Paths.basename(filename ?? "Untitled notebook")} · script metadata
|
|
48
|
+
</DialogDescription>
|
|
49
|
+
</DialogHeader>
|
|
50
|
+
{draft !== null && !loading ? (
|
|
51
|
+
<Suspense fallback={<Spinner />}>
|
|
52
|
+
<LazyAnyLanguageCodeMirror
|
|
53
|
+
language="toml"
|
|
54
|
+
theme={theme}
|
|
55
|
+
value={draft}
|
|
56
|
+
onChange={setDraft}
|
|
57
|
+
editable={!pending}
|
|
58
|
+
height="260px"
|
|
59
|
+
basicSetup={{ lineNumbers: false, foldGutter: false }}
|
|
60
|
+
className="border rounded overflow-hidden"
|
|
61
|
+
aria-label="Notebook manifest"
|
|
62
|
+
/>
|
|
63
|
+
</Suspense>
|
|
64
|
+
) : (
|
|
65
|
+
!error && <Spinner />
|
|
66
|
+
)}
|
|
67
|
+
{diagnostic && <SandboxErrorOutput error={diagnostic} />}
|
|
68
|
+
{conflict && (
|
|
69
|
+
<Button variant="text" size="sm" onClick={loadManifest}>
|
|
70
|
+
Discard draft and reload manifest
|
|
71
|
+
</Button>
|
|
72
|
+
)}
|
|
73
|
+
<DialogFooter className="items-center sm:justify-between gap-3">
|
|
74
|
+
<span className="text-xs text-muted-foreground mr-auto">
|
|
75
|
+
{loading
|
|
76
|
+
? "Loading…"
|
|
77
|
+
: dirty
|
|
78
|
+
? "Unsaved changes"
|
|
79
|
+
: draft !== null
|
|
80
|
+
? "Saved"
|
|
81
|
+
: ""}
|
|
82
|
+
</span>
|
|
83
|
+
<Button variant="text" size="sm" onClick={() => setOpen(false)}>
|
|
84
|
+
Close
|
|
85
|
+
</Button>
|
|
86
|
+
<Button
|
|
87
|
+
size="sm"
|
|
88
|
+
disabled={draft === null || pending}
|
|
89
|
+
onClick={saveAndSync}
|
|
90
|
+
>
|
|
91
|
+
{pending && <Spinner className="size-3 mr-2" />}
|
|
92
|
+
{loading
|
|
93
|
+
? "Loading…"
|
|
94
|
+
: saving
|
|
95
|
+
? "Saving…"
|
|
96
|
+
: pending
|
|
97
|
+
? "Syncing…"
|
|
98
|
+
: dirty
|
|
99
|
+
? "Save & sync"
|
|
100
|
+
: "Sync"}
|
|
101
|
+
</Button>
|
|
102
|
+
</DialogFooter>
|
|
103
|
+
</DialogContent>
|
|
104
|
+
</Dialog>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { useAtomValue } from "jotai";
|
|
3
|
+
import {
|
|
4
|
+
AlertCircleIcon,
|
|
5
|
+
ChevronUpIcon,
|
|
6
|
+
FileCodeIcon,
|
|
7
|
+
RefreshCwIcon,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { connectionNoticeAtom } from "@/core/network/connection-notice";
|
|
10
|
+
import { Spinner } from "@/components/icons/spinner";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import {
|
|
13
|
+
DropdownMenu,
|
|
14
|
+
DropdownMenuContent,
|
|
15
|
+
DropdownMenuItem,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "@/components/ui/dropdown-menu";
|
|
18
|
+
import { Tooltip } from "@/components/ui/tooltip";
|
|
19
|
+
import { isConnectedAtom } from "@/core/network/connection";
|
|
20
|
+
import {
|
|
21
|
+
sandboxActionsAtom,
|
|
22
|
+
sandboxAtom,
|
|
23
|
+
sandboxSyncAtom,
|
|
24
|
+
} from "@/core/packages/sandbox-state";
|
|
25
|
+
import { cn } from "@/utils/cn";
|
|
26
|
+
|
|
27
|
+
export function SandboxErrorOutput({ error }: { error: string }) {
|
|
28
|
+
return (
|
|
29
|
+
<pre
|
|
30
|
+
tabIndex={0}
|
|
31
|
+
aria-label="Error details"
|
|
32
|
+
className="mt-3 p-3 rounded border bg-muted/30 text-xs text-muted-foreground whitespace-pre min-w-0 max-w-full max-h-52 overflow-auto"
|
|
33
|
+
>
|
|
34
|
+
{error}
|
|
35
|
+
</pre>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SandboxStartupPanel() {
|
|
40
|
+
const notice = useAtomValue(connectionNoticeAtom);
|
|
41
|
+
const sandbox = useAtomValue(sandboxAtom);
|
|
42
|
+
const actions = useAtomValue(sandboxActionsAtom);
|
|
43
|
+
if (!notice) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex-1 min-h-0 overflow-auto p-4 text-sm">
|
|
48
|
+
<div
|
|
49
|
+
className={cn(
|
|
50
|
+
"flex items-center gap-2",
|
|
51
|
+
notice.pending
|
|
52
|
+
? "text-muted-foreground"
|
|
53
|
+
: "font-medium text-(--red-11)",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{notice.pending ? (
|
|
57
|
+
<Spinner className="size-4 shrink-0" aria-hidden={true} />
|
|
58
|
+
) : (
|
|
59
|
+
<AlertCircleIcon className="size-4 shrink-0" aria-hidden={true} />
|
|
60
|
+
)}
|
|
61
|
+
<h2>{notice.title}</h2>
|
|
62
|
+
</div>
|
|
63
|
+
{!notice.pending && (
|
|
64
|
+
<>
|
|
65
|
+
<p className="mt-2 text-foreground">{notice.description}</p>
|
|
66
|
+
{notice.error && <SandboxErrorOutput error={notice.error} />}
|
|
67
|
+
<div className="flex items-center gap-2 mt-3">
|
|
68
|
+
<Button
|
|
69
|
+
variant="outline"
|
|
70
|
+
size="xs"
|
|
71
|
+
disabled={!actions || sandbox?.manifest == null}
|
|
72
|
+
onClick={() => actions?.editManifest()}
|
|
73
|
+
>
|
|
74
|
+
Edit manifest…
|
|
75
|
+
</Button>
|
|
76
|
+
<Button
|
|
77
|
+
variant="text"
|
|
78
|
+
size="xs"
|
|
79
|
+
disabled={!actions}
|
|
80
|
+
onClick={() => actions?.sync()}
|
|
81
|
+
>
|
|
82
|
+
Retry sync
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
{sandbox?.manifest == null && (
|
|
86
|
+
<p className="mt-3 text-xs text-muted-foreground">
|
|
87
|
+
A manifest will be available once this new notebook starts.
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function SandboxFooter() {
|
|
97
|
+
const sandbox = useAtomValue(sandboxAtom);
|
|
98
|
+
const connected = useAtomValue(isConnectedAtom);
|
|
99
|
+
const actions = useAtomValue(sandboxActionsAtom);
|
|
100
|
+
const operation = useAtomValue(sandboxSyncAtom);
|
|
101
|
+
const notice = useAtomValue(connectionNoticeAtom);
|
|
102
|
+
if (!sandbox?.backend) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const pending = operation.pending || notice?.pending;
|
|
106
|
+
const status = operation.pending
|
|
107
|
+
? "Syncing sandbox…"
|
|
108
|
+
: (notice?.title ?? (connected ? "Sandbox ready" : "Sandbox not started"));
|
|
109
|
+
return (
|
|
110
|
+
<div className="border-t p-1.5 shrink-0">
|
|
111
|
+
<DropdownMenu>
|
|
112
|
+
<Tooltip content={status}>
|
|
113
|
+
<DropdownMenuTrigger asChild={true}>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:bg-accent data-[state=open]:bg-accent"
|
|
117
|
+
aria-label={`${sandbox.backend} sandbox actions`}
|
|
118
|
+
>
|
|
119
|
+
<span
|
|
120
|
+
role="status"
|
|
121
|
+
aria-label={status}
|
|
122
|
+
className={cn(
|
|
123
|
+
"size-1.5 shrink-0 rounded-full",
|
|
124
|
+
pending
|
|
125
|
+
? "bg-amber-500 motion-safe:animate-pulse"
|
|
126
|
+
: notice
|
|
127
|
+
? "bg-(--red-9)"
|
|
128
|
+
: connected
|
|
129
|
+
? "bg-emerald-500"
|
|
130
|
+
: "bg-muted-foreground",
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
133
|
+
<span>{sandbox.backend} sandbox</span>
|
|
134
|
+
<span className="ml-auto">
|
|
135
|
+
{pending
|
|
136
|
+
? operation.pending
|
|
137
|
+
? "Syncing…"
|
|
138
|
+
: notice?.title
|
|
139
|
+
: ""}
|
|
140
|
+
</span>
|
|
141
|
+
<ChevronUpIcon className="size-3" />
|
|
142
|
+
</button>
|
|
143
|
+
</DropdownMenuTrigger>
|
|
144
|
+
</Tooltip>
|
|
145
|
+
<DropdownMenuContent
|
|
146
|
+
side="top"
|
|
147
|
+
align="start"
|
|
148
|
+
className="w-52"
|
|
149
|
+
sideOffset={8}
|
|
150
|
+
>
|
|
151
|
+
<DropdownMenuItem
|
|
152
|
+
disabled={pending || !actions}
|
|
153
|
+
onSelect={() => {
|
|
154
|
+
void actions?.sync();
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<RefreshCwIcon className="size-3.5 mr-2" />
|
|
158
|
+
Sync
|
|
159
|
+
</DropdownMenuItem>
|
|
160
|
+
<DropdownMenuItem
|
|
161
|
+
disabled={!actions || sandbox.manifest === null}
|
|
162
|
+
onSelect={() => {
|
|
163
|
+
void actions?.editManifest();
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<FileCodeIcon className="size-3.5 mr-2" />
|
|
167
|
+
Edit manifest…
|
|
168
|
+
</DropdownMenuItem>
|
|
169
|
+
</DropdownMenuContent>
|
|
170
|
+
</DropdownMenu>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|