@marimo-team/islands 0.25.3-dev322 → 0.25.3-dev348
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/{ConnectedDataExplorerComponent-DkXBUBf3.js → ConnectedDataExplorerComponent-xliyyXNO.js} +16 -16
- package/dist/{ErrorBoundary-CYa6lyKz.js → ErrorBoundary-CIimOpqs.js} +7 -7
- package/dist/{abnfDiagram-VCTEODGH-BPS-pTU2.js → abnfDiagram-VCTEODGH-Dl9Nrtbf.js} +2 -2
- package/dist/{any-language-editor-DIS7IdV1.js → any-language-editor-CBHhdCC0.js} +2 -2
- package/dist/{architectureDiagram-5GKGNRK7-DB2MHOb7.js → architectureDiagram-5GKGNRK7-DTg032fQ.js} +1 -1
- package/dist/{assertNever-HbUt8DyH.js → assertNever-ie77PjMn.js} +13 -13
- package/dist/{chat-ui-85k50jaj.js → chat-ui-BV0qyfru.js} +51 -51
- package/dist/{check-CApDh1-n.js → check-DTIv5ZZ3.js} +1 -1
- package/dist/{chunk-IMKFNOWR-CjFCedjx.js → chunk-IMKFNOWR-CsFB-2LW.js} +1 -1
- package/dist/{chunk-SHT3W25Y-Dptltg0_.js → chunk-SHT3W25Y-ByuJHeBL.js} +1 -1
- package/dist/{chunk-SVP7TREG-2jBN760m.js → chunk-SVP7TREG-9oBZhbrr.js} +1 -1
- package/dist/{chunk-TICWLB2K-CS8BE-b9.js → chunk-TICWLB2K-Bx4m0cJ1.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-HtQWp-Cm.js → classDiagram-ZZMXUADV-Cms37-C9.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-DA9LETN-.js → classDiagram-v2-VYDZK3BY-qAom4Bi7.js} +2 -2
- package/dist/{code-visibility-CGcIJMGj.js → code-visibility-_-8-dvb4.js} +20 -20
- package/dist/{common-B-Ng3J3K.js → common-CNXuhphD.js} +620 -620
- package/dist/{config-B171m9zn.js → config-CvBnR945.js} +3 -3
- package/dist/{copy-icon-k2_Fx5Qv.js → copy-icon-DmtNidSa.js} +2 -2
- package/dist/{createReducer-BPPI11pP.js → createReducer-BxBCTvKn.js} +5 -5
- package/dist/{cynefinDiagram-5FMLGOSQ-eyocrg_E.js → cynefinDiagram-5FMLGOSQ-BjJZwMEz.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-Dtm96Nvx.js → dagre-GXQ25YYZ-Bohu7_09.js} +1 -1
- package/dist/{data-grid-overlay-editor-CUD6F0Vj.js → data-grid-overlay-editor--4Gk_rwA.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-C09TrdNO.js → diagram-S7CK7UJ4-Chl0n835.js} +1 -1
- package/dist/{diagram-UQ7AKVKN-vHUsSML6.js → diagram-UQ7AKVKN-DHx8nzy7.js} +1 -1
- package/dist/{diagram-VX7I27RA-Ch4mRgz8.js → diagram-VX7I27RA-CKdpv1U4.js} +1 -1
- package/dist/{diagram-Z3DM3KII-DyFQNdAL.js → diagram-Z3DM3KII-sY-sI--2.js} +1 -1
- package/dist/{dialog-C2Muh059.js → dialog-Cqu5Hp5U.js} +21 -21
- package/dist/{dropdown-menu-BGbNnuFq.js → dropdown-menu-DXEYk99S.js} +707 -707
- package/dist/{ebnfDiagram-PWID7BFC-N3SvlrCs.js → ebnfDiagram-PWID7BFC-CD0P2xeH.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-RR2q2xtd.js → erDiagram-RLTQ6QDP-BaKQuYxC.js} +1 -1
- package/dist/{error-banner-3_4Lc93Y.js → error-banner-DUB_qUeL.js} +58 -58
- package/dist/{error-entries-BhNAZWU1.js → error-entries-CTb_cxXY.js} +310 -310
- package/dist/{feedback-button-DK6zUzAk.js → feedback-button-BaA4r2dl.js} +7 -7
- package/dist/{flowDiagram-HODETNUW-nxd7WhlW.js → flowDiagram-HODETNUW-BcCpHh7-.js} +2 -2
- package/dist/{formats-aUcw6MrS.js → formats-hF6fETr6.js} +1 -1
- package/dist/{glide-data-editor-O-94UN5R.js → glide-data-editor-CRn3KXyR.js} +3129 -3119
- package/dist/{glide-utils-D4UP8TGv.js → glide-utils-Bnd_k48H.js} +2 -2
- package/dist/{globals-BVjYePCn.js → globals-_FmD97Ui.js} +3 -3
- package/dist/{html-to-image-DOOtP-4-.js → html-to-image-Bbl0PeC7.js} +478 -478
- package/dist/{infoDiagram-27XIBGKW-CUYXQKGf.js → infoDiagram-27XIBGKW-DWltseIa.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-v6gsCrlK.js → ishikawaDiagram-5VMMS53U-DHgepBPo.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-z236-0Ok.js → kanban-definition-UXKFOSKX-B2_BIjBq.js} +1 -1
- package/dist/main.js +6712 -6590
- package/dist/{mermaid-C7lQ_2LJ.js → mermaid--2K0cmt9.js} +34 -34
- package/dist/{mindmap-definition-YA3MSWOX--pZF7OCY.js → mindmap-definition-YA3MSWOX-DNebycIP.js} +1 -1
- package/dist/{number-overlay-editor-jkzzCN4e.js → number-overlay-editor-Bcg25qrp.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-Bgw0Zz_6.js → pegDiagram-XKGWAZYB-CH4U3II-.js} +2 -2
- package/dist/{pencil-RXFY-Ek8.js → pencil-MMnw1YQh.js} +1 -1
- package/dist/{pieDiagram-E7YTZNPT-B27tDYvK.js → pieDiagram-E7YTZNPT-pfcrdUCa.js} +1 -1
- package/dist/{process-output-CiX_Dl4L.js → process-output-Dw_sI4Xp.js} +4 -4
- package/dist/{railroadDiagram-O6MQD6OU-CBjPhGpj.js → railroadDiagram-O6MQD6OU-BZKpAbNG.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-DDDrLEw9.js → requirementDiagram-BXWQKSXE-DnyfIHe3.js} +1 -1
- package/dist/{reveal-component-CvPmNI5C.js → reveal-component-BVC491am.js} +18 -18
- package/dist/{select-Cv6lR39D.js → select-suaAxFuo.js} +286 -286
- package/dist/{sequenceDiagram-WJ2MYXX4-DQPaMygQ.js → sequenceDiagram-WJ2MYXX4-BeYuWX7N.js} +1 -1
- package/dist/{spec-BoCLWa-M.js → spec-BQNx2-D7.js} +4 -4
- package/dist/{stateDiagram-D77RDMKH-BDoDVVnq.js → stateDiagram-D77RDMKH-B8FxwAM2.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-KNko46uJ.js → stateDiagram-v2-MP3YSRHH-CzWLBqm1.js} +2 -2
- package/dist/{static-state-DgdOadIH.js → static-state-Al_hmf1n.js} +6 -6
- package/dist/style.css +1 -1
- package/dist/{swimlanes-42K2YHIH-boFjOuiI.js → swimlanes-42K2YHIH-CzKmGMts.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-BC4FLNwY.js → swimlanesDiagram-VR7AAH4N-DnWIUN_9.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-CLVPYA-v.js → timeline-definition-24CTP7MA-Czf2QN2R.js} +1 -1
- package/dist/{toast-CUuX7uXg.js → toast-CsJa1nGj.js} +66 -66
- package/dist/{tooltip-DBv8QUvS.js → tooltip-BVbJPkcl.js} +49 -49
- package/dist/{use-restore-focus-BxJh5Zfa.js → use-restore-focus-tPmARo1Q.js} +1035 -1022
- package/dist/{useDateFormatter-2k4mHRBq.js → useDateFormatter-B4ygYuUU.js} +2 -2
- package/dist/{useDeepCompareMemoize-Cb2GRQIS.js → useDeepCompareMemoize-Bcu0DkCS.js} +1 -1
- package/dist/{useLifecycle-B2fV3c8N.js → useLifecycle-MTdtnR3-.js} +1 -1
- package/dist/{vega-component-do3stf0K.js → vega-component-BhBbP4S7.js} +6 -6
- package/dist/{vennDiagram-4TSXK5OY-BLq7sNjb.js → vennDiagram-4TSXK5OY-BrHqWB1U.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-oyJEWWtb.js → wardleyDiagram-VM6X3IG4-Cms1K_3v.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-DMjWgQPm.js → xychartDiagram-S5SC5T6Z-_kUiODzS.js} +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +0 -2
- package/src/components/chat/__tests__/notebook-context.test.ts +199 -0
- package/src/components/chat/chat-panel.tsx +33 -15
- package/src/components/chat/notebook-context.ts +80 -0
- package/src/components/editor/cell/StopButton.tsx +3 -3
- package/src/components/editor/cell/cell-context-menu.tsx +18 -2
- package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +38 -1
- package/src/components/editor/cell/code/cell-editor.tsx +2 -1
- package/src/components/editor/cell/code/language-toggle.tsx +6 -4
- package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +9 -6
- package/src/components/slides/slide-cell-view.tsx +2 -1
- package/src/components/ui/__tests__/fullscreen.test.tsx +56 -1
- package/src/components/ui/fullscreen.tsx +18 -2
- package/src/components/ui/slider.tsx +6 -2
- package/src/core/codemirror/copilot/getCodes.ts +1 -1
- package/src/core/edit-app.tsx +5 -4
- package/src/core/executions/__tests__/input.test.ts +1 -1
- package/src/core/executions/__tests__/interrupt.test.ts +200 -0
- package/src/core/executions/__tests__/run.test.ts +3 -3
- package/src/core/executions/index.ts +1 -0
- package/src/core/executions/interrupt.ts +49 -0
- package/src/core/islands/bridge.ts +0 -2
- package/src/core/network/requests-network.ts +0 -15
- package/src/core/network/requests-static.ts +0 -2
- package/src/core/network/requests-toasting.tsx +0 -2
- package/src/core/network/types.ts +0 -3
- package/src/core/notebook/document/__tests__/model.test.ts +4 -4
- package/src/core/notebook/document/__tests__/test-host.test.ts +6 -6
- package/src/core/notebook/document/__tests__/test-host.ts +42 -46
- package/src/core/notebook/document/__tests__/test-notebook.ts +5 -4
- package/src/core/notebook/document/sync.ts +12 -5
- package/src/core/notebook/replica/__tests__/fixtures.ts +8 -0
- package/src/core/notebook/replica/backend.ts +1 -1
- package/src/core/saving/__tests__/document.test.tsx +1 -1
- package/src/core/transports/__tests__/static.test.ts +1 -1
- package/src/core/transports/__tests__/wasm.test.ts +1 -1
- package/src/core/transports/static.ts +8 -2
- package/src/core/transports/wasm.ts +2 -4
- package/src/core/wasm/__tests__/bridge.test.ts +170 -26
- package/src/core/wasm/bridge.ts +64 -19
- package/src/core/wasm/worker/types.ts +0 -1
- package/src/plugins/impl/DataEditorPlugin.tsx +32 -21
- package/src/plugins/impl/SliderPlugin.tsx +35 -3
- package/src/plugins/impl/__test__/slider-labels.test.tsx +30 -0
- package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +103 -12
- package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +40 -0
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +159 -4
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +30 -10
- package/src/plugins/impl/data-editor/glide-portal.tsx +5 -3
- package/src/plugins/layout/AccordionPlugin.tsx +2 -1
- package/src/plugins/layout/__tests__/AccordionPlugin.test.tsx +21 -0
|
@@ -20,15 +20,18 @@ afterEach(() => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
const conflict = {
|
|
23
|
-
ok: false
|
|
24
|
-
status:
|
|
23
|
+
ok: false,
|
|
24
|
+
status: 412,
|
|
25
25
|
problem: {
|
|
26
|
-
type: "
|
|
27
|
-
title: "
|
|
28
|
-
status:
|
|
26
|
+
type: "urn:marimo:problem:precondition-failed",
|
|
27
|
+
title: "Precondition failed",
|
|
28
|
+
status: 412,
|
|
29
29
|
detail: "cell c1 has changed since it was read.",
|
|
30
|
+
index: 0,
|
|
31
|
+
cell: document().cells.get("c1")!,
|
|
32
|
+
document: document().revision,
|
|
30
33
|
},
|
|
31
|
-
}
|
|
34
|
+
} satisfies Awaited<ReturnType<FakeEdit>>;
|
|
32
35
|
|
|
33
36
|
function setup(edit: FakeEdit) {
|
|
34
37
|
const store = createStore();
|
|
@@ -117,7 +117,8 @@ export const SlideCellView = ({ cell }: { cell: RuntimeCell }) => {
|
|
|
117
117
|
code={cell.code}
|
|
118
118
|
editorViewRef={editorViewRef}
|
|
119
119
|
currentLanguageAdapter={languageAdapter}
|
|
120
|
-
|
|
120
|
+
onAfterToggleSQL={afterToggleLanguage}
|
|
121
|
+
onAfterToggleMarkdown={afterToggleLanguage}
|
|
121
122
|
className="flex items-center gap-1"
|
|
122
123
|
/>
|
|
123
124
|
<div className="flex items-center shadow-none gap-1">
|
|
@@ -1,8 +1,63 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { act, render, renderHook } from "@testing-library/react";
|
|
3
3
|
import { useRef } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
4
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
useFullScreenClass,
|
|
8
|
+
useFullScreenElement,
|
|
9
|
+
useIsFullScreen,
|
|
10
|
+
withFullScreenAsRoot,
|
|
11
|
+
} from "../fullscreen";
|
|
12
|
+
|
|
13
|
+
describe("full screen element inside a shadow root", () => {
|
|
14
|
+
let host: HTMLElement;
|
|
15
|
+
let target: HTMLElement;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
host = document.createElement("div");
|
|
19
|
+
host.append(document.createElement("div"));
|
|
20
|
+
const shadowRoot = host.attachShadow({ mode: "open" });
|
|
21
|
+
target = document.createElement("div");
|
|
22
|
+
shadowRoot.append(target);
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
|
|
25
|
+
// The browser retargets document.fullscreenElement to the shadow host and
|
|
26
|
+
// reports the element itself on the shadow root.
|
|
27
|
+
Object.defineProperty(document, "fullscreenElement", {
|
|
28
|
+
configurable: true,
|
|
29
|
+
get: () => host,
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(shadowRoot, "fullscreenElement", {
|
|
32
|
+
configurable: true,
|
|
33
|
+
get: () => target,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
Reflect.deleteProperty(document, "fullscreenElement");
|
|
39
|
+
host.remove();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("resolves the element that is actually full screen", () => {
|
|
43
|
+
const { result } = renderHook(useFullScreenElement);
|
|
44
|
+
expect(result.current).toBe(target);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("portals into that element, not the host's light DOM", () => {
|
|
48
|
+
const Portal = withFullScreenAsRoot(
|
|
49
|
+
({ container }: { container?: Element | DocumentFragment | null }) =>
|
|
50
|
+
createPortal(
|
|
51
|
+
<span data-testid="portalled" />,
|
|
52
|
+
container ?? document.body,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
render(<Portal />);
|
|
56
|
+
|
|
57
|
+
expect(host.children).toHaveLength(1);
|
|
58
|
+
expect(target.querySelector('[data-testid="portalled"]')).not.toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
6
61
|
|
|
7
62
|
describe("useFullScreenClass", () => {
|
|
8
63
|
let fullscreenElement: Element | null = null;
|
|
@@ -38,15 +38,31 @@ export function useFullScreenClass() {
|
|
|
38
38
|
}, []);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The element that is actually full screen.
|
|
43
|
+
*
|
|
44
|
+
* `document.fullscreenElement` is retargeted to the outermost shadow host, so
|
|
45
|
+
* for a target inside a plugin's shadow root it reports the plugin host.
|
|
46
|
+
* Portalling into the host appends to its light DOM, which is not rendered and
|
|
47
|
+
* which the host observes to rebuild its children.
|
|
48
|
+
*/
|
|
49
|
+
export function getFullScreenElement(): Element | null {
|
|
50
|
+
let element = document.fullscreenElement;
|
|
51
|
+
while (element?.shadowRoot?.fullscreenElement) {
|
|
52
|
+
element = element.shadowRoot.fullscreenElement;
|
|
53
|
+
}
|
|
54
|
+
return element;
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* Get the full screen element if we are in full screen mode
|
|
43
59
|
*/
|
|
44
60
|
export function useFullScreenElement() {
|
|
45
61
|
const [fullScreenElement, setFullScreenElement] = useState<Element | null>(
|
|
46
|
-
|
|
62
|
+
getFullScreenElement,
|
|
47
63
|
);
|
|
48
64
|
useEventListener(document, "fullscreenchange", () => {
|
|
49
|
-
setFullScreenElement(
|
|
65
|
+
setFullScreenElement(getFullScreenElement());
|
|
50
66
|
});
|
|
51
67
|
return fullScreenElement;
|
|
52
68
|
}
|
|
@@ -18,8 +18,9 @@ const Slider = React.forwardRef<
|
|
|
18
18
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
|
19
19
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
|
20
20
|
valueMap: (sliderValue: number) => number;
|
|
21
|
+
maximumFractionDigits?: number;
|
|
21
22
|
}
|
|
22
|
-
>(({ className, valueMap, ...props }, ref) => {
|
|
23
|
+
>(({ className, valueMap, maximumFractionDigits, ...props }, ref) => {
|
|
23
24
|
const [open, openActions] = useBoolean(false);
|
|
24
25
|
const { locale } = useLocale();
|
|
25
26
|
|
|
@@ -70,7 +71,10 @@ const Slider = React.forwardRef<
|
|
|
70
71
|
<TooltipPortal>
|
|
71
72
|
{props.value != null && props.value.length === 1 && (
|
|
72
73
|
<TooltipContent key={props.value[0]}>
|
|
73
|
-
{prettyScientificNumber(valueMap(props.value[0]), {
|
|
74
|
+
{prettyScientificNumber(valueMap(props.value[0]), {
|
|
75
|
+
locale,
|
|
76
|
+
maximumFractionDigits,
|
|
77
|
+
})}
|
|
74
78
|
</TooltipContent>
|
|
75
79
|
)}
|
|
76
80
|
</TooltipPortal>
|
|
@@ -9,7 +9,7 @@ import type { Variables } from "@/core/variables/types";
|
|
|
9
9
|
import { Objects } from "@/utils/objects";
|
|
10
10
|
import { getEditorCodeAsPython } from "../language/utils";
|
|
11
11
|
|
|
12
|
-
const notebookCellCodes = atom((get) => {
|
|
12
|
+
export const notebookCellCodes = atom((get) => {
|
|
13
13
|
const notebook = get(notebookAtom);
|
|
14
14
|
const codes = Objects.fromEntries(
|
|
15
15
|
notebook.cellIds.map((id) => {
|
package/src/core/edit-app.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import { CellArray } from "../components/editor/renderers/cell-array";
|
|
|
27
27
|
import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
|
|
28
28
|
import { useHotkey } from "../hooks/useHotkey";
|
|
29
29
|
import { hasCellsAtom } from "./cells/cells";
|
|
30
|
-
import { notebookIsRunningAtom } from "./executions";
|
|
30
|
+
import { interruptRuntime, notebookIsRunningAtom } from "./executions";
|
|
31
|
+
import { store } from "./state/jotai";
|
|
31
32
|
import { numColumnsAtom } from "./cells/layout";
|
|
32
33
|
import { useCellActions } from "./cells/actions";
|
|
33
34
|
import type { AppConfig, UserConfig } from "./config/config-schema";
|
|
@@ -73,7 +74,7 @@ export const EditApp: React.FC<EditAppProps> = ({
|
|
|
73
74
|
const viewState = useAtomValue(viewStateAtom);
|
|
74
75
|
const numColumns = useAtomValue(numColumnsAtom);
|
|
75
76
|
const filename = useFilename();
|
|
76
|
-
const { sendComponentValues
|
|
77
|
+
const { sendComponentValues } = useRequestClient();
|
|
77
78
|
|
|
78
79
|
const isEditing = viewState.mode === "edit";
|
|
79
80
|
const isPresenting = viewState.mode === "present";
|
|
@@ -115,7 +116,7 @@ export const EditApp: React.FC<EditAppProps> = ({
|
|
|
115
116
|
runStaleCells();
|
|
116
117
|
});
|
|
117
118
|
useHotkey("global.interrupt", () => {
|
|
118
|
-
|
|
119
|
+
void interruptRuntime(store);
|
|
119
120
|
});
|
|
120
121
|
useHotkey("global.hideCode", () => {
|
|
121
122
|
togglePresenting();
|
|
@@ -208,7 +209,7 @@ export const EditApp: React.FC<EditAppProps> = ({
|
|
|
208
209
|
<Controls
|
|
209
210
|
presenting={isPresenting}
|
|
210
211
|
onTogglePresenting={togglePresenting}
|
|
211
|
-
onInterrupt={
|
|
212
|
+
onInterrupt={() => interruptRuntime(store)}
|
|
212
213
|
onRun={runStaleCells}
|
|
213
214
|
connectionState={connection.state}
|
|
214
215
|
running={isRunning}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { ProblemOf } from "@marimo-team/client";
|
|
4
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
5
|
+
import { createStore } from "jotai";
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { MockExecutions } from "@/__mocks__/executions";
|
|
8
|
+
import {
|
|
9
|
+
fakeBackend,
|
|
10
|
+
type FakeInterrupt,
|
|
11
|
+
} from "@/core/notebook/replica/__tests__/fixtures";
|
|
12
|
+
import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
|
|
13
|
+
import { interruptRuntime } from "../interrupt";
|
|
14
|
+
|
|
15
|
+
type Refusal = ProblemOf<"runtime.interrupt">;
|
|
16
|
+
|
|
17
|
+
const toast = vi.fn();
|
|
18
|
+
vi.mock("@/components/ui/use-toast", () => ({
|
|
19
|
+
toast: (...args: unknown[]) => toast(...args),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
function run(
|
|
23
|
+
id: string,
|
|
24
|
+
status: Protocol.Lifecycle = "running",
|
|
25
|
+
runtimeId = "r1",
|
|
26
|
+
): Protocol.Run {
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
notebook_id: "n1",
|
|
30
|
+
runtime_id: runtimeId,
|
|
31
|
+
cells: [],
|
|
32
|
+
status,
|
|
33
|
+
outcome: status === "completed" ? "succeeded" : null,
|
|
34
|
+
started_at: status === "queued" ? null : MockExecutions.T0,
|
|
35
|
+
completed_at: status === "completed" ? MockExecutions.T1 : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setup(runs: Protocol.Run[]) {
|
|
40
|
+
const store = createStore();
|
|
41
|
+
const replica = MockExecutions.seed(
|
|
42
|
+
store,
|
|
43
|
+
[],
|
|
44
|
+
MockExecutions.runtime("running"),
|
|
45
|
+
);
|
|
46
|
+
const interrupt = vi.fn<FakeInterrupt>(async () => ({
|
|
47
|
+
ok: true,
|
|
48
|
+
status: 202,
|
|
49
|
+
data: MockExecutions.runtime("running"),
|
|
50
|
+
}));
|
|
51
|
+
store.set(replica.backendAtom, fakeBackend({ interrupt }));
|
|
52
|
+
store.set(replica.executionsAtom, {
|
|
53
|
+
runs: new Map(runs.map((run) => [run.id, run])),
|
|
54
|
+
executions: new Map(),
|
|
55
|
+
latest: new Map(),
|
|
56
|
+
});
|
|
57
|
+
return { store, replica, interrupt };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("interruptRuntime", () => {
|
|
61
|
+
beforeEach(() => toast.mockClear());
|
|
62
|
+
|
|
63
|
+
it.each([
|
|
64
|
+
{ runs: [] },
|
|
65
|
+
{ runs: [run("queued", "queued")] },
|
|
66
|
+
{ runs: [run("browser"), run("code-mode"), run("queued", "queued")] },
|
|
67
|
+
])(
|
|
68
|
+
"interrupts the runtime once regardless of recorded runs: %j",
|
|
69
|
+
async ({ runs }) => {
|
|
70
|
+
const { store, replica, interrupt } = setup(runs);
|
|
71
|
+
|
|
72
|
+
await interruptRuntime(store);
|
|
73
|
+
|
|
74
|
+
expect(interrupt.mock.calls).toEqual([["n1"]]);
|
|
75
|
+
// The stream, not the acknowledgement, determines the work's outcome.
|
|
76
|
+
expect([...store.get(replica.executionsAtom).runs.values()]).toEqual(
|
|
77
|
+
runs,
|
|
78
|
+
);
|
|
79
|
+
expect(toast).not.toHaveBeenCalled();
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
it.each<Refusal>([
|
|
84
|
+
{
|
|
85
|
+
type: "urn:marimo:problem:no-runtime",
|
|
86
|
+
title: "The runtime has stopped",
|
|
87
|
+
status: 409,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "urn:marimo:problem:conflict",
|
|
91
|
+
title: "The runtime has stopped",
|
|
92
|
+
status: 409,
|
|
93
|
+
},
|
|
94
|
+
])(
|
|
95
|
+
"tolerates a runtime that stopped before the request: $type",
|
|
96
|
+
async (problem) => {
|
|
97
|
+
const { store, interrupt } = setup([]);
|
|
98
|
+
interrupt.mockResolvedValue({
|
|
99
|
+
ok: false,
|
|
100
|
+
status: problem.status,
|
|
101
|
+
problem,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await interruptRuntime(store);
|
|
105
|
+
|
|
106
|
+
expect(interrupt.mock.calls).toEqual([["n1"]]);
|
|
107
|
+
expect(toast).not.toHaveBeenCalled();
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it("sends the request even when the local runtime record is absent", async () => {
|
|
112
|
+
const { store, replica, interrupt } = setup([]);
|
|
113
|
+
store.set(replica.runtimeAtom, null);
|
|
114
|
+
|
|
115
|
+
await interruptRuntime(store);
|
|
116
|
+
|
|
117
|
+
expect(interrupt.mock.calls).toEqual([["n1"]]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does nothing without a connected notebook", async () => {
|
|
121
|
+
const { store, replica, interrupt } = setup([]);
|
|
122
|
+
store.set(replica.backendAtom, null);
|
|
123
|
+
await interruptRuntime(store);
|
|
124
|
+
store.set(activeNotebookReplicaAtom, null);
|
|
125
|
+
await interruptRuntime(store);
|
|
126
|
+
|
|
127
|
+
expect(interrupt).not.toHaveBeenCalled();
|
|
128
|
+
expect(toast).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each<{ problem: Refusal; toasts: unknown[] }>([
|
|
132
|
+
{
|
|
133
|
+
problem: {
|
|
134
|
+
type: "urn:marimo:problem:forbidden",
|
|
135
|
+
title: "Interrupt refused",
|
|
136
|
+
status: 403,
|
|
137
|
+
},
|
|
138
|
+
toasts: [],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
problem: {
|
|
142
|
+
type: "urn:marimo:problem:not-found",
|
|
143
|
+
title: "Interrupt refused",
|
|
144
|
+
status: 404,
|
|
145
|
+
},
|
|
146
|
+
toasts: [
|
|
147
|
+
[
|
|
148
|
+
{
|
|
149
|
+
title: "Failed to interrupt",
|
|
150
|
+
description: "Interrupt refused",
|
|
151
|
+
variant: "danger",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
problem: {
|
|
158
|
+
type: "urn:marimo:problem:internal",
|
|
159
|
+
title: "Interrupt refused",
|
|
160
|
+
status: 500,
|
|
161
|
+
},
|
|
162
|
+
toasts: [
|
|
163
|
+
[
|
|
164
|
+
{
|
|
165
|
+
title: "Failed to interrupt",
|
|
166
|
+
description: "Interrupt refused",
|
|
167
|
+
variant: "danger",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
])(
|
|
173
|
+
"handles a host refusal with status $problem.status",
|
|
174
|
+
async ({ problem, toasts }) => {
|
|
175
|
+
const { store, interrupt } = setup([run("running")]);
|
|
176
|
+
interrupt.mockResolvedValue({
|
|
177
|
+
ok: false,
|
|
178
|
+
status: problem.status,
|
|
179
|
+
problem,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await interruptRuntime(store);
|
|
183
|
+
|
|
184
|
+
expect(toast.mock.calls).toEqual(toasts);
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
it("reports transport failures without leaving an unhandled rejection", async () => {
|
|
189
|
+
const { store, interrupt } = setup([run("running")]);
|
|
190
|
+
interrupt.mockRejectedValue(new Error("Disconnected"));
|
|
191
|
+
|
|
192
|
+
await interruptRuntime(store);
|
|
193
|
+
|
|
194
|
+
expect(toast).toHaveBeenCalledExactlyOnceWith({
|
|
195
|
+
title: "Failed to interrupt",
|
|
196
|
+
description: "Disconnected",
|
|
197
|
+
variant: "danger",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -265,7 +265,7 @@ describe("runCells", () => {
|
|
|
265
265
|
ok: false,
|
|
266
266
|
status: 409,
|
|
267
267
|
problem: {
|
|
268
|
-
type: "
|
|
268
|
+
type: "urn:marimo:problem:conflict",
|
|
269
269
|
title: "Conflict",
|
|
270
270
|
status: 409,
|
|
271
271
|
detail: "the runtime is starting.",
|
|
@@ -286,7 +286,7 @@ describe("runCells", () => {
|
|
|
286
286
|
ok: false,
|
|
287
287
|
status: 403,
|
|
288
288
|
problem: {
|
|
289
|
-
type: "
|
|
289
|
+
type: "urn:marimo:problem:forbidden",
|
|
290
290
|
title: "Forbidden",
|
|
291
291
|
status: 403,
|
|
292
292
|
},
|
|
@@ -355,7 +355,7 @@ describe("runCells", () => {
|
|
|
355
355
|
ok: false,
|
|
356
356
|
status: 409,
|
|
357
357
|
problem: {
|
|
358
|
-
type: "
|
|
358
|
+
type: "urn:marimo:problem:conflict",
|
|
359
359
|
title: "Conflict",
|
|
360
360
|
status: 409,
|
|
361
361
|
},
|
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
} from "./atoms";
|
|
23
23
|
export type { CellFreshness, CellFreshnessLookup } from "./freshness";
|
|
24
24
|
export { replyInput } from "./input";
|
|
25
|
+
export { interruptRuntime } from "./interrupt";
|
|
25
26
|
export { runCells, syncEdits } from "./run";
|
|
26
27
|
export {
|
|
27
28
|
useCellExecution,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { createStore } from "jotai";
|
|
4
|
+
import { toast } from "@/components/ui/use-toast";
|
|
5
|
+
import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
|
|
6
|
+
import { prettyError } from "@/utils/errors";
|
|
7
|
+
import { Logger } from "@/utils/Logger";
|
|
8
|
+
|
|
9
|
+
/** Interrupt active runtime work and end queued runs. */
|
|
10
|
+
export async function interruptRuntime(
|
|
11
|
+
store: ReturnType<typeof createStore>,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const replica = store.get(activeNotebookReplicaAtom);
|
|
14
|
+
if (replica === null) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const notebook = store.get(replica.notebookAtom);
|
|
18
|
+
const backend = store.get(replica.backendAtom);
|
|
19
|
+
if (notebook === null || backend === null) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const result = await backend.notebook(notebook.id).interrupt();
|
|
24
|
+
if (result.ok) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// The runtime can stop before the request arrives.
|
|
28
|
+
if (result.status === 409) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (result.status === 403) {
|
|
32
|
+
Logger.warn("Interrupt refused, connection is read-only", result.problem);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
Logger.error("The host refused the interrupt", result.problem);
|
|
36
|
+
toast({
|
|
37
|
+
title: "Failed to interrupt",
|
|
38
|
+
description: result.problem.detail ?? result.problem.title,
|
|
39
|
+
variant: "danger",
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
Logger.error("Failed to interrupt", error);
|
|
43
|
+
toast({
|
|
44
|
+
title: "Failed to interrupt",
|
|
45
|
+
description: prettyError(error),
|
|
46
|
+
variant: "danger",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -375,8 +375,6 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
375
375
|
public saveLayout = throwNotImplemented;
|
|
376
376
|
public sendCopy = throwNotImplemented;
|
|
377
377
|
public sendRunScratchpad = throwNotImplemented;
|
|
378
|
-
public sendStdin = throwNotImplemented;
|
|
379
|
-
public sendInterrupt = throwNotImplemented;
|
|
380
378
|
public sendShutdown = throwNotImplemented;
|
|
381
379
|
public sendFormat = throwNotImplemented;
|
|
382
380
|
public sendInstallMissingPackages = throwNotImplemented;
|
|
@@ -94,13 +94,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
94
94
|
})
|
|
95
95
|
.then(handleResponse);
|
|
96
96
|
},
|
|
97
|
-
sendInterrupt: () => {
|
|
98
|
-
return getClient()
|
|
99
|
-
.POST("/api/kernel/interrupt", {
|
|
100
|
-
params: getParams(),
|
|
101
|
-
})
|
|
102
|
-
.then(handleResponseReturnNull);
|
|
103
|
-
},
|
|
104
97
|
sendShutdown: () => {
|
|
105
98
|
return getClient()
|
|
106
99
|
.POST("/api/kernel/shutdown")
|
|
@@ -165,14 +158,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
165
158
|
})
|
|
166
159
|
.then(handleResponseReturnNull);
|
|
167
160
|
},
|
|
168
|
-
sendStdin: (request) => {
|
|
169
|
-
return getClient()
|
|
170
|
-
.POST("/api/kernel/stdin", {
|
|
171
|
-
body: request,
|
|
172
|
-
params: getParams(),
|
|
173
|
-
})
|
|
174
|
-
.then(handleResponseReturnNull);
|
|
175
|
-
},
|
|
176
161
|
sendInstallMissingPackages: (request) => {
|
|
177
162
|
return getClient()
|
|
178
163
|
.POST("/api/kernel/install_missing_packages", {
|
|
@@ -44,13 +44,11 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
44
44
|
sendRename: throwNotInEditMode,
|
|
45
45
|
saveLayout: throwNotInEditMode,
|
|
46
46
|
sendCopy: throwNotInEditMode,
|
|
47
|
-
sendInterrupt: throwNotInEditMode,
|
|
48
47
|
sendShutdown: throwNotInEditMode,
|
|
49
48
|
sendFormat: throwNotInEditMode,
|
|
50
49
|
sendCodeCompletionRequest: throwNotInEditMode,
|
|
51
50
|
saveUserConfig: throwNotInEditMode,
|
|
52
51
|
saveAppConfig: throwNotInEditMode,
|
|
53
|
-
sendStdin: throwNotInEditMode,
|
|
54
52
|
readCode: throwNotInEditMode,
|
|
55
53
|
readSnippets: throwNotInEditMode,
|
|
56
54
|
previewDatasetColumn: throwNotInEditMode,
|
|
@@ -19,13 +19,11 @@ export function createErrorToastingRequests(
|
|
|
19
19
|
sendRename: "Failed to rename",
|
|
20
20
|
saveLayout: "", // The save reports it
|
|
21
21
|
sendCopy: "Failed to copy",
|
|
22
|
-
sendInterrupt: "Failed to interrupt",
|
|
23
22
|
sendShutdown: "Failed to shutdown",
|
|
24
23
|
sendFormat: "Failed to format",
|
|
25
24
|
sendCodeCompletionRequest: "Failed to complete code",
|
|
26
25
|
saveUserConfig: "Failed to save user config",
|
|
27
26
|
saveAppConfig: "Failed to save app config",
|
|
28
|
-
sendStdin: "Failed to send stdin",
|
|
29
27
|
readCode: "Failed to read code",
|
|
30
28
|
readSnippets: "Failed to fetch snippets",
|
|
31
29
|
previewDatasetColumn: "Failed to fetch data sources",
|
|
@@ -116,7 +116,6 @@ export type ShutdownSessionRequest = schemas["ShutdownSessionRequest"];
|
|
|
116
116
|
export type Snippet = schemas["Snippet"];
|
|
117
117
|
export type SnippetSection = schemas["SnippetSection"];
|
|
118
118
|
export type Snippets = schemas["Snippets"];
|
|
119
|
-
export type StdinRequest = schemas["StdinRequest"];
|
|
120
119
|
export type SuccessResponse = schemas["SuccessResponse"];
|
|
121
120
|
export type UpdateUIElementValuesRequest =
|
|
122
121
|
schemas["UpdateUIElementValuesRequest"];
|
|
@@ -163,9 +162,7 @@ export interface EditRequests {
|
|
|
163
162
|
*/
|
|
164
163
|
saveLayout: (request: SaveLayoutRequest) => Promise<null>;
|
|
165
164
|
sendCopy: (request: CopyNotebookRequest) => Promise<null>;
|
|
166
|
-
sendStdin: (request: StdinRequest) => Promise<null>;
|
|
167
165
|
sendRunScratchpad: (request: ExecuteScratchpadRequest) => Promise<null>;
|
|
168
|
-
sendInterrupt: () => Promise<null>;
|
|
169
166
|
sendShutdown: () => Promise<null>;
|
|
170
167
|
sendFormat: (request: FormatCellsRequest) => Promise<FormatResponse>;
|
|
171
168
|
sendCodeCompletionRequest: (request: CodeCompletionRequest) => Promise<null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { ResultOf } from "@marimo-team/client";
|
|
4
4
|
import type * as Protocol from "@marimo-team/client/protocol";
|
|
5
5
|
import { atom, createStore } from "jotai/vanilla";
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -11,7 +11,7 @@ import { createDocumentModel } from "../model";
|
|
|
11
11
|
import { type FakeEdit, fakeBackend } from "../../replica/__tests__/fixtures";
|
|
12
12
|
import { cell, document, withCells } from "./fixtures";
|
|
13
13
|
|
|
14
|
-
function response(confirmed: Document):
|
|
14
|
+
function response(confirmed: Document): ResultOf<"notebook.edit"> {
|
|
15
15
|
return {
|
|
16
16
|
ok: true,
|
|
17
17
|
status: 200,
|
|
@@ -41,9 +41,9 @@ function setup({ connected = true } = {}) {
|
|
|
41
41
|
const source = atom<Document | null>(document());
|
|
42
42
|
const unmounted = vi.fn();
|
|
43
43
|
source.onMount = () => unmounted;
|
|
44
|
-
const replies: Deferred<
|
|
44
|
+
const replies: Deferred<ResultOf<"notebook.edit">>[] = [];
|
|
45
45
|
const sent = vi.fn((_edit: SentEdit) => {
|
|
46
|
-
const reply = new Deferred<
|
|
46
|
+
const reply = new Deferred<ResultOf<"notebook.edit">>();
|
|
47
47
|
replies.push(reply);
|
|
48
48
|
return reply.promise;
|
|
49
49
|
});
|
|
@@ -24,16 +24,16 @@ it("matches the portable host examples for accepted and conflicting edits", () =
|
|
|
24
24
|
notebookId: "n1",
|
|
25
25
|
idempotencyKey: "rejected",
|
|
26
26
|
changes: Protocol.EditRequest.parse(
|
|
27
|
-
protocolFixture("edit-
|
|
27
|
+
protocolFixture("stale-edit-request.json"),
|
|
28
28
|
).changes,
|
|
29
29
|
});
|
|
30
30
|
const problem = Protocol.Problem.parse(
|
|
31
|
-
protocolFixture("problems/
|
|
31
|
+
protocolFixture("problems/precondition-failed.json"),
|
|
32
32
|
);
|
|
33
33
|
expect(rejected).toEqual({
|
|
34
34
|
result: {
|
|
35
35
|
ok: false,
|
|
36
|
-
status:
|
|
36
|
+
status: 412,
|
|
37
37
|
problem: { ...problem, detail: expect.any(String) },
|
|
38
38
|
},
|
|
39
39
|
});
|
|
@@ -48,7 +48,7 @@ it("rejects an entire batch when a later precondition is stale", () => {
|
|
|
48
48
|
{ kind: "delete_cell", id: "c3", base_etag: "stale" },
|
|
49
49
|
]);
|
|
50
50
|
expect(result).toMatchObject({
|
|
51
|
-
result: { ok: false, status:
|
|
51
|
+
result: { ok: false, status: 412, problem: { index: 1 } },
|
|
52
52
|
});
|
|
53
53
|
expect(host.document()).toEqual(initial);
|
|
54
54
|
expect(result.update).toBeUndefined();
|
|
@@ -67,7 +67,7 @@ it("keeps successful and rejected receipts tied to their original payload and re
|
|
|
67
67
|
notebookId: "n1",
|
|
68
68
|
idempotencyKey: "stale",
|
|
69
69
|
changes: Protocol.EditRequest.parse(
|
|
70
|
-
protocolFixture("edit-
|
|
70
|
+
protocolFixture("stale-edit-request.json"),
|
|
71
71
|
).changes,
|
|
72
72
|
};
|
|
73
73
|
const rejected = host.process(stale);
|
|
@@ -86,7 +86,7 @@ it("keeps successful and rejected receipts tied to their original payload and re
|
|
|
86
86
|
vi.advanceTimersByTime(300_000);
|
|
87
87
|
expect(host.process(request).result).toMatchObject({
|
|
88
88
|
ok: false,
|
|
89
|
-
status:
|
|
89
|
+
status: 412,
|
|
90
90
|
});
|
|
91
91
|
expect(host.document()).toEqual(latest);
|
|
92
92
|
});
|