@marimo-team/frontend 0.19.7-dev23 → 0.19.7-dev25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CellStatus-DFhwmbo5.js → CellStatus-oBL2iale.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-Cosas-Z0.js → ConnectedDataExplorerComponent-ec_bSRbX.js} +1 -1
- package/dist/assets/{ErrorBoundary-BU1OKJ3L.js → ErrorBoundary-ChCiwl15.js} +1 -1
- package/dist/assets/{ImperativeModal-DstvzsTs.js → ImperativeModal-CUbWEBci.js} +1 -1
- package/dist/assets/{JsonOutput-Dmfgex9T.js → JsonOutput-BKP4rBIw.js} +3 -3
- package/dist/assets/{LazyAnyLanguageCodeMirror-ygeIsKeo.js → LazyAnyLanguageCodeMirror-yzHjsVJt.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-eoDwRuKU.js → MarimoErrorOutput-cw9gEb4T.js} +1 -1
- package/dist/assets/{RenderHTML-DuJkj1GV.js → RenderHTML-Bgz4e362.js} +1 -1
- package/dist/assets/{add-cell-with-ai-COxBrLrH.js → add-cell-with-ai-DNbX7Ctg.js} +1 -1
- package/dist/assets/{add-database-form-BtP14Anj.js → add-database-form-CP39qGit.js} +1 -1
- package/dist/assets/{agent-panel-Baphp2Rh.js → agent-panel-Bak-DtIc.js} +1 -1
- package/dist/assets/{ai-model-dropdown-GvB-5iLY.js → ai-model-dropdown-Dpr0DUJN.js} +1 -1
- package/dist/assets/{alert-dialog-Bv1sALHm.js → alert-dialog-DwQffb13.js} +1 -1
- package/dist/assets/{any-language-editor-WGtuXPVf.js → any-language-editor-BMlwpEZ4.js} +1 -1
- package/dist/assets/{app-config-button-BK2508Jf.js → app-config-button-CAaYaq0L.js} +1 -1
- package/dist/assets/{button-B3uq-Cpf.js → button-YC1gW_kJ.js} +1 -1
- package/dist/assets/{cache-panel-DFXoPp-r.js → cache-panel-BHX9f5Bx.js} +1 -1
- package/dist/assets/{capabilities-CUpom6r4.js → capabilities-MM7JYRxj.js} +1 -1
- package/dist/assets/{capitalize-CXGTjPyK.js → capitalize-CkclHYWI.js} +1 -1
- package/dist/assets/{cell-editor-Cqw8VDah.js → cell-editor-B6jD1bv8.js} +10 -10
- package/dist/assets/{cell-link-BITMERKQ.js → cell-link-CyIWDVXR.js} +1 -1
- package/dist/assets/{cells-Cvo5AIrZ.js → cells-DRiwSVs0.js} +1 -1
- package/dist/assets/{chat-components-BV83l1rZ.js → chat-components-DDIZD9FU.js} +1 -1
- package/dist/assets/{chat-display-C4qhRyWq.js → chat-display-D-Wa-1Hv.js} +1 -1
- package/dist/assets/{chat-panel-BxxPae_R.js → chat-panel-RtFQiyUV.js} +1 -1
- package/dist/assets/{column-preview-BvwEAGt6.js → column-preview-C72fVZoP.js} +1 -1
- package/dist/assets/{command-BjWSp3sa.js → command-Bq3e85NA.js} +1 -1
- package/dist/assets/{command-palette-aT5upvTH.js → command-palette-D4NcN7PV.js} +1 -1
- package/dist/assets/{common-BLddv5HY.js → common-CeF2TOUZ.js} +1 -1
- package/dist/assets/{config-BgpK7vqH.js → config-CIrPQIbt.js} +1 -1
- package/dist/assets/{copy-r7i0SKI4.js → copy-Bv2DBpIS.js} +1 -1
- package/dist/assets/{copy-icon-wZr2McVB.js → copy-icon-BhONVREY.js} +1 -1
- package/dist/assets/{createReducer-Cki97cx5.js → createReducer-Dnna-AUO.js} +1 -1
- package/dist/assets/{datasource-D2hNaG_n.js → datasource-AZ3l2P48.js} +1 -1
- package/dist/assets/{dates-CxJmszXT.js → dates-Dhn1r-h6.js} +1 -1
- package/dist/assets/{dependency-graph-panel-CBiKFUBG.js → dependency-graph-panel-i3yTswTN.js} +1 -1
- package/dist/assets/{dialog-C5Pa_iIq.js → dialog-CxGKN4C_.js} +1 -1
- package/dist/assets/{dist-R0oOvu5B.js → dist-CdxIjAOP.js} +1 -1
- package/dist/assets/{documentation-panel-ChkvmAB1.js → documentation-panel-J2fwLjEP.js} +1 -1
- package/dist/assets/download-Y3BpaOoI.js +6 -0
- package/dist/assets/{edit-page-BfA_Lg2x.js → edit-page-Bpl6BN5G.js} +6 -6
- package/dist/assets/{error-banner-BWJsOpnc.js → error-banner-DUzsIXtq.js} +1 -1
- package/dist/assets/{error-panel-Bnhv5zxn.js → error-panel-Cpfr8TZw.js} +1 -1
- package/dist/assets/{field-BN2j4cag.js → field-BEg1eC0P.js} +1 -1
- package/dist/assets/{file-explorer-panel-CkOMaMbq.js → file-explorer-panel-DsMwvQX7.js} +1 -1
- package/dist/assets/{floating-outline-CxfziveS.js → floating-outline-yvPiOGQ2.js} +1 -1
- package/dist/assets/{focus-BeWVOW9Q.js → focus-B7xu7kpl.js} +1 -1
- package/dist/assets/{form-DazVYGCT.js → form-BpwI8bBX.js} +1 -1
- package/dist/assets/{formats-BBDL2N4i.js → formats-W1SWxSE3.js} +1 -1
- package/dist/assets/{glide-data-editor-DEwgx2xp.js → glide-data-editor-DVw6MlCk.js} +1 -1
- package/dist/assets/{globals-uEPg-4pq.js → globals-BW23ny3Q.js} +1 -1
- package/dist/assets/{home-page-Dtcd7Trh.js → home-page-C-JIKt-2.js} +1 -1
- package/dist/assets/hooks-2c2seyHG.js +1 -0
- package/dist/assets/hotkeys-BHHWjLlp.js +1 -0
- package/dist/assets/{html-to-image-CJgqxZci.js → html-to-image-Cy_LYuWW.js} +1 -1
- package/dist/assets/{index-DCjsZDGq.js → index-C4si8YAb.js} +21 -26
- package/dist/assets/index-DHsXOI_M.css +2 -0
- package/dist/assets/{input-BeGfEf2S.js → input-pAun1m1X.js} +1 -1
- package/dist/assets/{kiosk-mode-Djm3JPwk.js → kiosk-mode-D9jdUD5P.js} +1 -1
- package/dist/assets/{label-C_OuzPjQ.js → label-Be1daUcS.js} +1 -1
- package/dist/assets/{layout-BkGjQBnQ.js → layout-BjIcxFO0.js} +4 -4
- package/dist/assets/links-C-GGaW8R.js +1 -0
- package/dist/assets/{logs-panel-BsnCP3we.js → logs-panel-XVSOzGFv.js} +1 -1
- package/dist/assets/{maps-vcWR7nnr.js → maps-t9yNKYA8.js} +1 -1
- package/dist/assets/{markdown-renderer-CoQm4UxN.js → markdown-renderer-BM4a9QeZ.js} +1 -1
- package/dist/assets/{mermaid-R2vBM-JH.js → mermaid-BG5ill_a.js} +1 -1
- package/dist/assets/{mode-DjraKyN2.js → mode-BD6zDBBd.js} +1 -1
- package/dist/assets/{multi-map-Cwq--tzY.js → multi-map-C8GlnP-4.js} +1 -1
- package/dist/assets/{name-cell-input-CHmzPoeN.js → name-cell-input-CGevX71g.js} +1 -1
- package/dist/assets/{numbers-CSY3JIgn.js → numbers-iQunIAXf.js} +1 -1
- package/dist/assets/{outline-panel-DMECjI9i.js → outline-panel-BgZXYbEO.js} +1 -1
- package/dist/assets/{packages-panel-CbXV6Rc8.js → packages-panel-8NDUaEZw.js} +1 -1
- package/dist/assets/{panels-B55q0DEo.js → panels-05G4QYfq.js} +1 -1
- package/dist/assets/{process-output-CwcoTocd.js → process-output-nNOt7QtH.js} +1 -1
- package/dist/assets/{readonly-python-code-BlVsu50E.js → readonly-python-code-CfLdThzF.js} +1 -1
- package/dist/assets/{renderShortcut-Dyrbz79Y.js → renderShortcut-DHc-p-_c.js} +1 -1
- package/dist/assets/{run-page-6DKviU71.js → run-page-DvWw2rQu.js} +1 -1
- package/dist/assets/{runs-CAvk6jVz.js → runs-bjsj1D88.js} +1 -1
- package/dist/assets/{scratchpad-panel-CM5InA2G.js → scratchpad-panel-Bzreyj8q.js} +1 -1
- package/dist/assets/{secrets-panel-XDvrD2PC.js → secrets-panel-Br6CcsOE.js} +1 -1
- package/dist/assets/{select-UFziUNxL.js → select-V5IdpNiR.js} +1 -1
- package/dist/assets/{session-panel-CRStSDDj.js → session-panel-CxCUFR_x.js} +1 -1
- package/dist/assets/{share-CKfNi8fD.js → share-CbPtIlnM.js} +1 -1
- package/dist/assets/{slides-component-0GonPC6Y.js → slides-component-DP2pxhDh.js} +1 -1
- package/dist/assets/{snippets-panel-Db-biIgP.js → snippets-panel-BeqbqiKB.js} +1 -1
- package/dist/assets/{spec-DJ3YTCel.js → spec-BSxN05D8.js} +1 -1
- package/dist/assets/{state-D-CqcbQE.js → state-D7LZMIOW.js} +1 -1
- package/dist/assets/{state-CS48Wh7M.js → state-DnUQ1uxR.js} +1 -1
- package/dist/assets/{switch-Di5kBaS8.js → switch-Cx8dJhf6.js} +1 -1
- package/dist/assets/{terminal-nu6YfkVm.js → terminal-DNwT6UrR.js} +1 -1
- package/dist/assets/{textarea-hNyWE2r_.js → textarea-DellDgP4.js} +1 -1
- package/dist/assets/{tooltip-CE4l3v3B.js → tooltip-BGrCWNss.js} +1 -1
- package/dist/assets/{tracing-CDNRUjb9.js → tracing-7U4WTsN0.js} +1 -1
- package/dist/assets/{tracing-panel-CHVKa-9o.js → tracing-panel-BPW1q7K3.js} +2 -2
- package/dist/assets/{type-D8l_U05h.js → type-DUK-1jKc.js} +1 -1
- package/dist/assets/{types-DWpF5HiT.js → types-D5X7ikSD.js} +1 -1
- package/dist/assets/{useAddCell-CklpKCq2.js → useAddCell-bMoxoWAg.js} +1 -1
- package/dist/assets/{useBoolean-BE72e3yb.js → useBoolean-B1Xeh6vA.js} +1 -1
- package/dist/assets/{useCellActionButton-CQQ8dBy_.js → useCellActionButton-0MDUWMcl.js} +1 -1
- package/dist/assets/{useDeleteCell-BRWEDSc9.js → useDeleteCell-BUAQb9OH.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-_ibASMD1.js → useDependencyPanelTab-odtlfdG2.js} +1 -1
- package/dist/assets/{useIframeCapabilities-BVYqWHmA.js → useIframeCapabilities-DuIDx9mD.js} +1 -1
- package/dist/assets/{useInstallPackage-Dxl_p6oW.js → useInstallPackage-Bdnnp5fe.js} +1 -1
- package/dist/assets/{useLifecycle-DvpL8DUJ.js → useLifecycle-ChNbzbYY.js} +1 -1
- package/dist/assets/useNotebookActions-CNWP5yqL.js +1 -0
- package/dist/assets/{useRunCells-jbEa8WGV.js → useRunCells-DyBzMbHe.js} +1 -1
- package/dist/assets/{useSplitCell-CdjW9REr.js → useSplitCell-CyFavQ2l.js} +1 -1
- package/dist/assets/{useTheme-CuyH5VNX.js → useTheme-DUdVAZI8.js} +1 -1
- package/dist/assets/{utilities.esm-eBoXu7lR.js → utilities.esm-j_F9mYkM.js} +1 -1
- package/dist/assets/{utils-CSDCHxwI.js → utils-DXvhzCGS.js} +1 -1
- package/dist/assets/{vega-component-B3_9VVhH.js → vega-component-Dd3MCYZO.js} +1 -1
- package/dist/assets/{write-secret-modal-DfRIeQB5.js → write-secret-modal-CpmU5gbF.js} +1 -1
- package/dist/index.html +67 -67
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +5 -3
- package/src/components/editor/header/filename-form.tsx +15 -2
- package/src/components/editor/navigation/__tests__/navigation.test.ts +2 -0
- package/src/components/ui/progress.tsx +22 -5
- package/src/core/export/__tests__/hooks.test.ts +42 -19
- package/src/core/export/hooks.ts +33 -32
- package/src/core/saving/save-component.tsx +1 -0
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/__tests__/objects.test.ts +263 -0
- package/src/utils/__tests__/progress.test.ts +156 -0
- package/src/utils/download.ts +7 -2
- package/src/utils/objects.ts +3 -0
- package/src/utils/progress.ts +61 -0
- package/src/utils/toast-progress.tsx +41 -0
- package/dist/assets/download-kUMZIq8-.js +0 -1
- package/dist/assets/hooks-DxXRX-38.js +0 -1
- package/dist/assets/hotkeys-DghjL7BQ.js +0 -1
- package/dist/assets/index-CodxHczV.css +0 -2
- package/dist/assets/links-CUKo4afc.js +0 -1
- package/dist/assets/useNotebookActions-REoVp8xc.js +0 -1
package/src/core/export/hooks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { useInterval } from "@/hooks/useInterval";
|
|
|
7
7
|
import { getImageDataUrlForCell } from "@/utils/download";
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { Objects } from "@/utils/objects";
|
|
10
|
+
import { ProgressState } from "@/utils/progress";
|
|
10
11
|
import { cellsRuntimeAtom } from "../cells/cells";
|
|
11
12
|
import type { CellId } from "../cells/ids";
|
|
12
13
|
import { connectionAtom } from "../network/connection";
|
|
@@ -63,10 +64,11 @@ export function useAutoExport() {
|
|
|
63
64
|
|
|
64
65
|
useInterval(
|
|
65
66
|
async () => {
|
|
66
|
-
await updateCellOutputsWithScreenshots(
|
|
67
|
+
await updateCellOutputsWithScreenshots({
|
|
68
|
+
progress: ProgressState.indeterminate(),
|
|
67
69
|
takeScreenshots,
|
|
68
70
|
updateCellOutputs,
|
|
69
|
-
);
|
|
71
|
+
});
|
|
70
72
|
await autoExportAsIPYNB({
|
|
71
73
|
download: false,
|
|
72
74
|
});
|
|
@@ -95,6 +97,8 @@ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
|
95
97
|
"application/vnd.vega.v6+json",
|
|
96
98
|
]);
|
|
97
99
|
|
|
100
|
+
type ScreenshotResults = Record<CellId, ["image/png", string]>;
|
|
101
|
+
|
|
98
102
|
/**
|
|
99
103
|
* Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
|
|
100
104
|
* @returns A map of cell IDs to their screenshots data.
|
|
@@ -103,7 +107,7 @@ export function useEnrichCellOutputs() {
|
|
|
103
107
|
const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
|
|
104
108
|
const cellRuntimes = useAtomValue(cellsRuntimeAtom);
|
|
105
109
|
|
|
106
|
-
return async (): Promise<
|
|
110
|
+
return async (progress: ProgressState): Promise<ScreenshotResults> => {
|
|
107
111
|
const trackedCellsOutput: Record<CellId, unknown> = {};
|
|
108
112
|
|
|
109
113
|
const cellsToCaptureScreenshot: [CellId, unknown][] = [];
|
|
@@ -129,43 +133,40 @@ export function useEnrichCellOutputs() {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
// Capture screenshots
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
CellId,
|
|
142
|
-
["image/png", string],
|
|
143
|
-
];
|
|
144
|
-
} catch (error) {
|
|
145
|
-
Logger.error(`Error screenshotting cell ${cellId}:`, error);
|
|
146
|
-
return null;
|
|
136
|
+
const total = cellsToCaptureScreenshot.length;
|
|
137
|
+
progress.addTotal(total);
|
|
138
|
+
const results: ScreenshotResults = {};
|
|
139
|
+
for (const [cellId] of cellsToCaptureScreenshot) {
|
|
140
|
+
try {
|
|
141
|
+
const dataUrl = await getImageDataUrlForCell(cellId, false);
|
|
142
|
+
if (!dataUrl) {
|
|
143
|
+
Logger.error(`Failed to capture screenshot for cell ${cellId}`);
|
|
144
|
+
continue;
|
|
147
145
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
results[cellId] = ["image/png", dataUrl];
|
|
147
|
+
} catch (error) {
|
|
148
|
+
Logger.error(`Error screenshotting cell ${cellId}:`, error);
|
|
149
|
+
} finally {
|
|
150
|
+
progress.increment(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return results;
|
|
156
155
|
};
|
|
157
156
|
}
|
|
158
157
|
|
|
159
158
|
/**
|
|
160
159
|
* Utility function to take screenshots of cells with HTML outputs and update the cell outputs.
|
|
161
160
|
*/
|
|
162
|
-
export async function updateCellOutputsWithScreenshots(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
161
|
+
export async function updateCellOutputsWithScreenshots(opts: {
|
|
162
|
+
progress: ProgressState;
|
|
163
|
+
takeScreenshots: (progress: ProgressState) => Promise<ScreenshotResults>;
|
|
164
|
+
updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>;
|
|
165
|
+
}) {
|
|
166
|
+
const { progress, takeScreenshots, updateCellOutputs } = opts;
|
|
166
167
|
try {
|
|
167
|
-
const cellIdsToOutput = await takeScreenshots();
|
|
168
|
-
if (
|
|
168
|
+
const cellIdsToOutput = await takeScreenshots(progress);
|
|
169
|
+
if (Objects.size(cellIdsToOutput) > 0) {
|
|
169
170
|
await updateCellOutputs({ cellIdsToOutput });
|
|
170
171
|
}
|
|
171
172
|
} catch (error) {
|
|
@@ -57,10 +57,12 @@ describe("withLoadingToast", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
expect(toast).toHaveBeenCalledTimes(1);
|
|
60
|
-
expect(toast).toHaveBeenCalledWith(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
expect(toast).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
title: "Loading...",
|
|
63
|
+
duration: Infinity,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
64
66
|
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
|
65
67
|
expect(result).toBe("success");
|
|
66
68
|
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Objects } from "../objects";
|
|
4
|
+
|
|
5
|
+
describe("Objects", () => {
|
|
6
|
+
describe("EMPTY", () => {
|
|
7
|
+
it("should be an empty frozen object", () => {
|
|
8
|
+
expect(Objects.EMPTY).toEqual({});
|
|
9
|
+
expect(Object.isFrozen(Objects.EMPTY)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("mapValues", () => {
|
|
14
|
+
it("should map values of an object", () => {
|
|
15
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
16
|
+
const result = Objects.mapValues(obj, (v) => v * 2);
|
|
17
|
+
expect(result).toEqual({ a: 2, b: 4, c: 6 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should pass key as second argument", () => {
|
|
21
|
+
const obj = { a: 1, b: 2 };
|
|
22
|
+
const result = Objects.mapValues(obj, (v, k) => `${k}:${v}`);
|
|
23
|
+
expect(result).toEqual({ a: "a:1", b: "b:2" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle empty objects", () => {
|
|
27
|
+
const result = Objects.mapValues({}, (v) => v);
|
|
28
|
+
expect(result).toEqual({});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return falsy input unchanged", () => {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
expect(Objects.mapValues(null as any, (v) => v)).toBe(null);
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
expect(Objects.mapValues(undefined as any, (v) => v)).toBe(undefined);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("fromEntries", () => {
|
|
40
|
+
it("should create object from entries", () => {
|
|
41
|
+
const entries: [string, number][] = [
|
|
42
|
+
["a", 1],
|
|
43
|
+
["b", 2],
|
|
44
|
+
];
|
|
45
|
+
expect(Objects.fromEntries(entries)).toEqual({ a: 1, b: 2 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should handle empty entries", () => {
|
|
49
|
+
expect(Objects.fromEntries([])).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should handle numeric keys", () => {
|
|
53
|
+
const entries: [number, string][] = [
|
|
54
|
+
[1, "a"],
|
|
55
|
+
[2, "b"],
|
|
56
|
+
];
|
|
57
|
+
expect(Objects.fromEntries(entries)).toEqual({ 1: "a", 2: "b" });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("entries", () => {
|
|
62
|
+
it("should return entries of an object", () => {
|
|
63
|
+
const obj = { a: 1, b: 2 };
|
|
64
|
+
const entries = Objects.entries(obj);
|
|
65
|
+
expect(entries).toContainEqual(["a", 1]);
|
|
66
|
+
expect(entries).toContainEqual(["b", 2]);
|
|
67
|
+
expect(entries).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should handle empty objects", () => {
|
|
71
|
+
expect(Objects.entries({})).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("keys", () => {
|
|
76
|
+
it("should return keys of an object", () => {
|
|
77
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
78
|
+
const keys = Objects.keys(obj);
|
|
79
|
+
expect(keys).toContain("a");
|
|
80
|
+
expect(keys).toContain("b");
|
|
81
|
+
expect(keys).toContain("c");
|
|
82
|
+
expect(keys).toHaveLength(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should handle empty objects", () => {
|
|
86
|
+
expect(Objects.keys({})).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("size", () => {
|
|
91
|
+
it("should return the number of keys", () => {
|
|
92
|
+
expect(Objects.size({ a: 1, b: 2, c: 3 })).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return 0 for empty objects", () => {
|
|
96
|
+
expect(Objects.size({})).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("keyBy", () => {
|
|
101
|
+
it("should key items by specified key function", () => {
|
|
102
|
+
const items = [
|
|
103
|
+
{ id: "a", value: 1 },
|
|
104
|
+
{ id: "b", value: 2 },
|
|
105
|
+
];
|
|
106
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
a: { id: "a", value: 1 },
|
|
109
|
+
b: { id: "b", value: 2 },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should skip items with undefined keys", () => {
|
|
114
|
+
const items = [
|
|
115
|
+
{ id: "a", value: 1 },
|
|
116
|
+
{ id: undefined as unknown as string, value: 2 },
|
|
117
|
+
{ id: "c", value: 3 },
|
|
118
|
+
];
|
|
119
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
a: { id: "a", value: 1 },
|
|
122
|
+
c: { id: "c", value: 3 },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should handle empty arrays", () => {
|
|
127
|
+
expect(Objects.keyBy([], (item) => item)).toEqual({});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should use last item when keys collide", () => {
|
|
131
|
+
const items = [
|
|
132
|
+
{ id: "a", value: 1 },
|
|
133
|
+
{ id: "a", value: 2 },
|
|
134
|
+
];
|
|
135
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
136
|
+
expect(result).toEqual({ a: { id: "a", value: 2 } });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("collect", () => {
|
|
141
|
+
it("should collect and transform items", () => {
|
|
142
|
+
const items = [
|
|
143
|
+
{ id: "a", value: 1 },
|
|
144
|
+
{ id: "b", value: 2 },
|
|
145
|
+
];
|
|
146
|
+
const result = Objects.collect(
|
|
147
|
+
items,
|
|
148
|
+
(item) => item.id,
|
|
149
|
+
(item) => item.value * 2,
|
|
150
|
+
);
|
|
151
|
+
expect(result).toEqual({ a: 2, b: 4 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should handle empty arrays", () => {
|
|
155
|
+
const result = Objects.collect(
|
|
156
|
+
[],
|
|
157
|
+
(item) => item,
|
|
158
|
+
(item) => item,
|
|
159
|
+
);
|
|
160
|
+
expect(result).toEqual({});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("groupBy", () => {
|
|
165
|
+
it("should group items by key", () => {
|
|
166
|
+
const items = [
|
|
167
|
+
{ category: "a", value: 1 },
|
|
168
|
+
{ category: "b", value: 2 },
|
|
169
|
+
{ category: "a", value: 3 },
|
|
170
|
+
];
|
|
171
|
+
const result = Objects.groupBy(
|
|
172
|
+
items,
|
|
173
|
+
(item) => item.category,
|
|
174
|
+
(item) => item.value,
|
|
175
|
+
);
|
|
176
|
+
expect(result).toEqual({
|
|
177
|
+
a: [1, 3],
|
|
178
|
+
b: [2],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should skip items with undefined keys", () => {
|
|
183
|
+
const items = [
|
|
184
|
+
{ category: "a", value: 1 },
|
|
185
|
+
{ category: undefined as unknown as string, value: 2 },
|
|
186
|
+
{ category: "a", value: 3 },
|
|
187
|
+
];
|
|
188
|
+
const result = Objects.groupBy(
|
|
189
|
+
items,
|
|
190
|
+
(item) => item.category,
|
|
191
|
+
(item) => item.value,
|
|
192
|
+
);
|
|
193
|
+
expect(result).toEqual({ a: [1, 3] });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should handle empty arrays", () => {
|
|
197
|
+
const result = Objects.groupBy(
|
|
198
|
+
[],
|
|
199
|
+
(item) => item,
|
|
200
|
+
(item) => item,
|
|
201
|
+
);
|
|
202
|
+
expect(result).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("filter", () => {
|
|
207
|
+
it("should filter object entries by predicate", () => {
|
|
208
|
+
const obj = { a: 1, b: 2, c: 3, d: 4 };
|
|
209
|
+
const result = Objects.filter(obj, (v) => v % 2 === 0);
|
|
210
|
+
expect(result).toEqual({ b: 2, d: 4 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should pass key as second argument", () => {
|
|
214
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
215
|
+
const result = Objects.filter(obj, (_, k) => k !== "b");
|
|
216
|
+
expect(result).toEqual({ a: 1, c: 3 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should handle empty objects", () => {
|
|
220
|
+
const result = Objects.filter({}, () => true);
|
|
221
|
+
expect(result).toEqual({});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should return empty object when nothing matches", () => {
|
|
225
|
+
const obj = { a: 1, b: 2 };
|
|
226
|
+
const result = Objects.filter(obj, () => false);
|
|
227
|
+
expect(result).toEqual({});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("omit", () => {
|
|
232
|
+
it("should omit specified keys from object", () => {
|
|
233
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
234
|
+
const result = Objects.omit(obj, ["b"]);
|
|
235
|
+
expect(result).toEqual({ a: 1, c: 3 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should omit multiple keys", () => {
|
|
239
|
+
const obj = { a: 1, b: 2, c: 3, d: 4 };
|
|
240
|
+
const result = Objects.omit(obj, ["a", "c"]);
|
|
241
|
+
expect(result).toEqual({ b: 2, d: 4 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should handle keys provided as Set", () => {
|
|
245
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
246
|
+
const result = Objects.omit(obj, new Set(["a", "c"] as const));
|
|
247
|
+
expect(result).toEqual({ b: 2 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("should handle omitting non-existent keys", () => {
|
|
251
|
+
const obj = { a: 1, b: 2 };
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
253
|
+
const result = Objects.omit(obj, ["c" as any]);
|
|
254
|
+
expect(result).toEqual({ a: 1, b: 2 });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should return all properties when omitting empty array", () => {
|
|
258
|
+
const obj = { a: 1, b: 2 };
|
|
259
|
+
const result = Objects.omit(obj, []);
|
|
260
|
+
expect(result).toEqual({ a: 1, b: 2 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ProgressState } from "../progress";
|
|
4
|
+
|
|
5
|
+
describe("ProgressState", () => {
|
|
6
|
+
describe("constructor", () => {
|
|
7
|
+
it("should initialize with a numeric total", () => {
|
|
8
|
+
const progress = new ProgressState(100);
|
|
9
|
+
expect(progress.getProgress()).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should initialize with indeterminate total", () => {
|
|
13
|
+
const progress = new ProgressState("indeterminate");
|
|
14
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("static indeterminate", () => {
|
|
19
|
+
it("should create an indeterminate progress state", () => {
|
|
20
|
+
const progress = ProgressState.indeterminate();
|
|
21
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("addTotal", () => {
|
|
26
|
+
it("should add to the total when numeric", () => {
|
|
27
|
+
const progress = new ProgressState(100);
|
|
28
|
+
progress.addTotal(50);
|
|
29
|
+
// Progress is 0, total is now 150
|
|
30
|
+
expect(progress.getProgress()).toBe(0);
|
|
31
|
+
progress.increment(75);
|
|
32
|
+
expect(progress.getProgress()).toBe(50); // 75/150 * 100 = 50
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should convert indeterminate to numeric when adding total", () => {
|
|
36
|
+
const progress = ProgressState.indeterminate();
|
|
37
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
38
|
+
progress.addTotal(100);
|
|
39
|
+
expect(progress.getProgress()).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("increment", () => {
|
|
44
|
+
it("should increment the progress", () => {
|
|
45
|
+
const progress = new ProgressState(100);
|
|
46
|
+
progress.increment(25);
|
|
47
|
+
expect(progress.getProgress()).toBe(25);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should accumulate multiple increments", () => {
|
|
51
|
+
const progress = new ProgressState(100);
|
|
52
|
+
progress.increment(25);
|
|
53
|
+
progress.increment(25);
|
|
54
|
+
progress.increment(25);
|
|
55
|
+
expect(progress.getProgress()).toBe(75);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should allow progress beyond 100%", () => {
|
|
59
|
+
const progress = new ProgressState(100);
|
|
60
|
+
progress.increment(150);
|
|
61
|
+
expect(progress.getProgress()).toBe(150);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("getProgress", () => {
|
|
66
|
+
it("should return indeterminate for indeterminate state", () => {
|
|
67
|
+
const progress = ProgressState.indeterminate();
|
|
68
|
+
progress.increment(50); // increment has no visible effect
|
|
69
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should return correct percentage", () => {
|
|
73
|
+
const progress = new ProgressState(200);
|
|
74
|
+
progress.increment(50);
|
|
75
|
+
expect(progress.getProgress()).toBe(25); // 50/200 * 100 = 25
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should return 0 when no progress made", () => {
|
|
79
|
+
const progress = new ProgressState(100);
|
|
80
|
+
expect(progress.getProgress()).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should return 100 when complete", () => {
|
|
84
|
+
const progress = new ProgressState(100);
|
|
85
|
+
progress.increment(100);
|
|
86
|
+
expect(progress.getProgress()).toBe(100);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("subscribe", () => {
|
|
91
|
+
it("should notify listeners on increment", () => {
|
|
92
|
+
const progress = new ProgressState(100);
|
|
93
|
+
const listener = vi.fn();
|
|
94
|
+
progress.subscribe(listener);
|
|
95
|
+
|
|
96
|
+
progress.increment(25);
|
|
97
|
+
expect(listener).toHaveBeenCalledWith(25);
|
|
98
|
+
|
|
99
|
+
progress.increment(25);
|
|
100
|
+
expect(listener).toHaveBeenCalledWith(50);
|
|
101
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should notify listeners on addTotal", () => {
|
|
105
|
+
const progress = new ProgressState(100);
|
|
106
|
+
const listener = vi.fn();
|
|
107
|
+
progress.subscribe(listener);
|
|
108
|
+
|
|
109
|
+
progress.addTotal(100);
|
|
110
|
+
expect(listener).toHaveBeenCalledWith(0); // 0/200 = 0%
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should notify listeners when converting from indeterminate", () => {
|
|
114
|
+
const progress = ProgressState.indeterminate();
|
|
115
|
+
const listener = vi.fn();
|
|
116
|
+
progress.subscribe(listener);
|
|
117
|
+
|
|
118
|
+
progress.addTotal(100);
|
|
119
|
+
expect(listener).toHaveBeenCalledWith(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should return unsubscribe function", () => {
|
|
123
|
+
const progress = new ProgressState(100);
|
|
124
|
+
const listener = vi.fn();
|
|
125
|
+
const unsubscribe = progress.subscribe(listener);
|
|
126
|
+
|
|
127
|
+
progress.increment(25);
|
|
128
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
129
|
+
|
|
130
|
+
unsubscribe();
|
|
131
|
+
progress.increment(25);
|
|
132
|
+
expect(listener).toHaveBeenCalledTimes(1); // no additional calls
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should support multiple listeners", () => {
|
|
136
|
+
const progress = new ProgressState(100);
|
|
137
|
+
const listener1 = vi.fn();
|
|
138
|
+
const listener2 = vi.fn();
|
|
139
|
+
progress.subscribe(listener1);
|
|
140
|
+
progress.subscribe(listener2);
|
|
141
|
+
|
|
142
|
+
progress.increment(50);
|
|
143
|
+
expect(listener1).toHaveBeenCalledWith(50);
|
|
144
|
+
expect(listener2).toHaveBeenCalledWith(50);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should pass indeterminate to listeners", () => {
|
|
148
|
+
const progress = ProgressState.indeterminate();
|
|
149
|
+
const listener = vi.fn();
|
|
150
|
+
progress.subscribe(listener);
|
|
151
|
+
|
|
152
|
+
progress.increment(50);
|
|
153
|
+
expect(listener).toHaveBeenCalledWith("indeterminate");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/utils/download.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import React from "react";
|
|
3
4
|
import { toast } from "@/components/ui/use-toast";
|
|
4
5
|
import { type CellId, CellOutputId } from "@/core/cells/ids";
|
|
5
6
|
import { getRequestClient } from "@/core/network/requests";
|
|
@@ -9,6 +10,8 @@ import { prettyError } from "./errors";
|
|
|
9
10
|
import { toPng } from "./html-to-image";
|
|
10
11
|
import { captureIframeAsImage } from "./iframe";
|
|
11
12
|
import { Logger } from "./Logger";
|
|
13
|
+
import { ProgressState } from "./progress";
|
|
14
|
+
import { ToastProgress } from "./toast-progress";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Show a loading toast while an async operation is in progress.
|
|
@@ -16,14 +19,16 @@ import { Logger } from "./Logger";
|
|
|
16
19
|
*/
|
|
17
20
|
export async function withLoadingToast<T>(
|
|
18
21
|
title: string,
|
|
19
|
-
fn: () => Promise<T>,
|
|
22
|
+
fn: (progress: ProgressState) => Promise<T>,
|
|
20
23
|
): Promise<T> {
|
|
24
|
+
const progress = ProgressState.indeterminate();
|
|
21
25
|
const loadingToast = toast({
|
|
22
26
|
title,
|
|
27
|
+
description: React.createElement(ToastProgress, { progress }),
|
|
23
28
|
duration: Infinity,
|
|
24
29
|
});
|
|
25
30
|
try {
|
|
26
|
-
const result = await fn();
|
|
31
|
+
const result = await fn(progress);
|
|
27
32
|
loadingToast.dismiss();
|
|
28
33
|
return result;
|
|
29
34
|
} catch (error) {
|
package/src/utils/objects.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const Objects = {
|
|
|
32
32
|
keys<K extends string | number>(obj: Record<K, unknown>): K[] {
|
|
33
33
|
return Object.keys(obj) as K[];
|
|
34
34
|
},
|
|
35
|
+
size<K extends string | number>(obj: Record<K, unknown>): number {
|
|
36
|
+
return Object.keys(obj).length;
|
|
37
|
+
},
|
|
35
38
|
/**
|
|
36
39
|
* Type-safe keyBy
|
|
37
40
|
*/
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
export type ProgressListener = (progress: number | "indeterminate") => void;
|
|
3
|
+
|
|
4
|
+
export class ProgressState {
|
|
5
|
+
private progress = 0;
|
|
6
|
+
private total: number | "indeterminate";
|
|
7
|
+
private listeners = new Set<ProgressListener>();
|
|
8
|
+
|
|
9
|
+
constructor(total: number | "indeterminate") {
|
|
10
|
+
this.total = total;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static indeterminate(): ProgressState {
|
|
14
|
+
return new ProgressState("indeterminate");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
addTotal(total: number) {
|
|
18
|
+
if (this.total === "indeterminate") {
|
|
19
|
+
this.total = total;
|
|
20
|
+
} else {
|
|
21
|
+
this.total += total;
|
|
22
|
+
}
|
|
23
|
+
this.notifyListeners();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update the progress by the given increment.
|
|
28
|
+
*/
|
|
29
|
+
increment(increment: number) {
|
|
30
|
+
this.progress += increment;
|
|
31
|
+
this.notifyListeners();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the progress as a percentage (0-100)
|
|
36
|
+
*/
|
|
37
|
+
getProgress(): number | "indeterminate" {
|
|
38
|
+
if (this.total === "indeterminate") {
|
|
39
|
+
return "indeterminate";
|
|
40
|
+
}
|
|
41
|
+
return (this.progress / this.total) * 100;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subscribe to progress updates.
|
|
46
|
+
* Returns an unsubscribe function.
|
|
47
|
+
*/
|
|
48
|
+
subscribe(listener: ProgressListener): () => void {
|
|
49
|
+
this.listeners.add(listener);
|
|
50
|
+
return () => {
|
|
51
|
+
this.listeners.delete(listener);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private notifyListeners() {
|
|
56
|
+
const progress = this.getProgress();
|
|
57
|
+
for (const listener of this.listeners) {
|
|
58
|
+
listener(progress);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|