@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.
Files changed (134) hide show
  1. package/dist/assets/{CellStatus-DFhwmbo5.js → CellStatus-oBL2iale.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-Cosas-Z0.js → ConnectedDataExplorerComponent-ec_bSRbX.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-BU1OKJ3L.js → ErrorBoundary-ChCiwl15.js} +1 -1
  4. package/dist/assets/{ImperativeModal-DstvzsTs.js → ImperativeModal-CUbWEBci.js} +1 -1
  5. package/dist/assets/{JsonOutput-Dmfgex9T.js → JsonOutput-BKP4rBIw.js} +3 -3
  6. package/dist/assets/{LazyAnyLanguageCodeMirror-ygeIsKeo.js → LazyAnyLanguageCodeMirror-yzHjsVJt.js} +2 -2
  7. package/dist/assets/{MarimoErrorOutput-eoDwRuKU.js → MarimoErrorOutput-cw9gEb4T.js} +1 -1
  8. package/dist/assets/{RenderHTML-DuJkj1GV.js → RenderHTML-Bgz4e362.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-COxBrLrH.js → add-cell-with-ai-DNbX7Ctg.js} +1 -1
  10. package/dist/assets/{add-database-form-BtP14Anj.js → add-database-form-CP39qGit.js} +1 -1
  11. package/dist/assets/{agent-panel-Baphp2Rh.js → agent-panel-Bak-DtIc.js} +1 -1
  12. package/dist/assets/{ai-model-dropdown-GvB-5iLY.js → ai-model-dropdown-Dpr0DUJN.js} +1 -1
  13. package/dist/assets/{alert-dialog-Bv1sALHm.js → alert-dialog-DwQffb13.js} +1 -1
  14. package/dist/assets/{any-language-editor-WGtuXPVf.js → any-language-editor-BMlwpEZ4.js} +1 -1
  15. package/dist/assets/{app-config-button-BK2508Jf.js → app-config-button-CAaYaq0L.js} +1 -1
  16. package/dist/assets/{button-B3uq-Cpf.js → button-YC1gW_kJ.js} +1 -1
  17. package/dist/assets/{cache-panel-DFXoPp-r.js → cache-panel-BHX9f5Bx.js} +1 -1
  18. package/dist/assets/{capabilities-CUpom6r4.js → capabilities-MM7JYRxj.js} +1 -1
  19. package/dist/assets/{capitalize-CXGTjPyK.js → capitalize-CkclHYWI.js} +1 -1
  20. package/dist/assets/{cell-editor-Cqw8VDah.js → cell-editor-B6jD1bv8.js} +10 -10
  21. package/dist/assets/{cell-link-BITMERKQ.js → cell-link-CyIWDVXR.js} +1 -1
  22. package/dist/assets/{cells-Cvo5AIrZ.js → cells-DRiwSVs0.js} +1 -1
  23. package/dist/assets/{chat-components-BV83l1rZ.js → chat-components-DDIZD9FU.js} +1 -1
  24. package/dist/assets/{chat-display-C4qhRyWq.js → chat-display-D-Wa-1Hv.js} +1 -1
  25. package/dist/assets/{chat-panel-BxxPae_R.js → chat-panel-RtFQiyUV.js} +1 -1
  26. package/dist/assets/{column-preview-BvwEAGt6.js → column-preview-C72fVZoP.js} +1 -1
  27. package/dist/assets/{command-BjWSp3sa.js → command-Bq3e85NA.js} +1 -1
  28. package/dist/assets/{command-palette-aT5upvTH.js → command-palette-D4NcN7PV.js} +1 -1
  29. package/dist/assets/{common-BLddv5HY.js → common-CeF2TOUZ.js} +1 -1
  30. package/dist/assets/{config-BgpK7vqH.js → config-CIrPQIbt.js} +1 -1
  31. package/dist/assets/{copy-r7i0SKI4.js → copy-Bv2DBpIS.js} +1 -1
  32. package/dist/assets/{copy-icon-wZr2McVB.js → copy-icon-BhONVREY.js} +1 -1
  33. package/dist/assets/{createReducer-Cki97cx5.js → createReducer-Dnna-AUO.js} +1 -1
  34. package/dist/assets/{datasource-D2hNaG_n.js → datasource-AZ3l2P48.js} +1 -1
  35. package/dist/assets/{dates-CxJmszXT.js → dates-Dhn1r-h6.js} +1 -1
  36. package/dist/assets/{dependency-graph-panel-CBiKFUBG.js → dependency-graph-panel-i3yTswTN.js} +1 -1
  37. package/dist/assets/{dialog-C5Pa_iIq.js → dialog-CxGKN4C_.js} +1 -1
  38. package/dist/assets/{dist-R0oOvu5B.js → dist-CdxIjAOP.js} +1 -1
  39. package/dist/assets/{documentation-panel-ChkvmAB1.js → documentation-panel-J2fwLjEP.js} +1 -1
  40. package/dist/assets/download-Y3BpaOoI.js +6 -0
  41. package/dist/assets/{edit-page-BfA_Lg2x.js → edit-page-Bpl6BN5G.js} +6 -6
  42. package/dist/assets/{error-banner-BWJsOpnc.js → error-banner-DUzsIXtq.js} +1 -1
  43. package/dist/assets/{error-panel-Bnhv5zxn.js → error-panel-Cpfr8TZw.js} +1 -1
  44. package/dist/assets/{field-BN2j4cag.js → field-BEg1eC0P.js} +1 -1
  45. package/dist/assets/{file-explorer-panel-CkOMaMbq.js → file-explorer-panel-DsMwvQX7.js} +1 -1
  46. package/dist/assets/{floating-outline-CxfziveS.js → floating-outline-yvPiOGQ2.js} +1 -1
  47. package/dist/assets/{focus-BeWVOW9Q.js → focus-B7xu7kpl.js} +1 -1
  48. package/dist/assets/{form-DazVYGCT.js → form-BpwI8bBX.js} +1 -1
  49. package/dist/assets/{formats-BBDL2N4i.js → formats-W1SWxSE3.js} +1 -1
  50. package/dist/assets/{glide-data-editor-DEwgx2xp.js → glide-data-editor-DVw6MlCk.js} +1 -1
  51. package/dist/assets/{globals-uEPg-4pq.js → globals-BW23ny3Q.js} +1 -1
  52. package/dist/assets/{home-page-Dtcd7Trh.js → home-page-C-JIKt-2.js} +1 -1
  53. package/dist/assets/hooks-2c2seyHG.js +1 -0
  54. package/dist/assets/hotkeys-BHHWjLlp.js +1 -0
  55. package/dist/assets/{html-to-image-CJgqxZci.js → html-to-image-Cy_LYuWW.js} +1 -1
  56. package/dist/assets/{index-DCjsZDGq.js → index-C4si8YAb.js} +21 -26
  57. package/dist/assets/index-DHsXOI_M.css +2 -0
  58. package/dist/assets/{input-BeGfEf2S.js → input-pAun1m1X.js} +1 -1
  59. package/dist/assets/{kiosk-mode-Djm3JPwk.js → kiosk-mode-D9jdUD5P.js} +1 -1
  60. package/dist/assets/{label-C_OuzPjQ.js → label-Be1daUcS.js} +1 -1
  61. package/dist/assets/{layout-BkGjQBnQ.js → layout-BjIcxFO0.js} +4 -4
  62. package/dist/assets/links-C-GGaW8R.js +1 -0
  63. package/dist/assets/{logs-panel-BsnCP3we.js → logs-panel-XVSOzGFv.js} +1 -1
  64. package/dist/assets/{maps-vcWR7nnr.js → maps-t9yNKYA8.js} +1 -1
  65. package/dist/assets/{markdown-renderer-CoQm4UxN.js → markdown-renderer-BM4a9QeZ.js} +1 -1
  66. package/dist/assets/{mermaid-R2vBM-JH.js → mermaid-BG5ill_a.js} +1 -1
  67. package/dist/assets/{mode-DjraKyN2.js → mode-BD6zDBBd.js} +1 -1
  68. package/dist/assets/{multi-map-Cwq--tzY.js → multi-map-C8GlnP-4.js} +1 -1
  69. package/dist/assets/{name-cell-input-CHmzPoeN.js → name-cell-input-CGevX71g.js} +1 -1
  70. package/dist/assets/{numbers-CSY3JIgn.js → numbers-iQunIAXf.js} +1 -1
  71. package/dist/assets/{outline-panel-DMECjI9i.js → outline-panel-BgZXYbEO.js} +1 -1
  72. package/dist/assets/{packages-panel-CbXV6Rc8.js → packages-panel-8NDUaEZw.js} +1 -1
  73. package/dist/assets/{panels-B55q0DEo.js → panels-05G4QYfq.js} +1 -1
  74. package/dist/assets/{process-output-CwcoTocd.js → process-output-nNOt7QtH.js} +1 -1
  75. package/dist/assets/{readonly-python-code-BlVsu50E.js → readonly-python-code-CfLdThzF.js} +1 -1
  76. package/dist/assets/{renderShortcut-Dyrbz79Y.js → renderShortcut-DHc-p-_c.js} +1 -1
  77. package/dist/assets/{run-page-6DKviU71.js → run-page-DvWw2rQu.js} +1 -1
  78. package/dist/assets/{runs-CAvk6jVz.js → runs-bjsj1D88.js} +1 -1
  79. package/dist/assets/{scratchpad-panel-CM5InA2G.js → scratchpad-panel-Bzreyj8q.js} +1 -1
  80. package/dist/assets/{secrets-panel-XDvrD2PC.js → secrets-panel-Br6CcsOE.js} +1 -1
  81. package/dist/assets/{select-UFziUNxL.js → select-V5IdpNiR.js} +1 -1
  82. package/dist/assets/{session-panel-CRStSDDj.js → session-panel-CxCUFR_x.js} +1 -1
  83. package/dist/assets/{share-CKfNi8fD.js → share-CbPtIlnM.js} +1 -1
  84. package/dist/assets/{slides-component-0GonPC6Y.js → slides-component-DP2pxhDh.js} +1 -1
  85. package/dist/assets/{snippets-panel-Db-biIgP.js → snippets-panel-BeqbqiKB.js} +1 -1
  86. package/dist/assets/{spec-DJ3YTCel.js → spec-BSxN05D8.js} +1 -1
  87. package/dist/assets/{state-D-CqcbQE.js → state-D7LZMIOW.js} +1 -1
  88. package/dist/assets/{state-CS48Wh7M.js → state-DnUQ1uxR.js} +1 -1
  89. package/dist/assets/{switch-Di5kBaS8.js → switch-Cx8dJhf6.js} +1 -1
  90. package/dist/assets/{terminal-nu6YfkVm.js → terminal-DNwT6UrR.js} +1 -1
  91. package/dist/assets/{textarea-hNyWE2r_.js → textarea-DellDgP4.js} +1 -1
  92. package/dist/assets/{tooltip-CE4l3v3B.js → tooltip-BGrCWNss.js} +1 -1
  93. package/dist/assets/{tracing-CDNRUjb9.js → tracing-7U4WTsN0.js} +1 -1
  94. package/dist/assets/{tracing-panel-CHVKa-9o.js → tracing-panel-BPW1q7K3.js} +2 -2
  95. package/dist/assets/{type-D8l_U05h.js → type-DUK-1jKc.js} +1 -1
  96. package/dist/assets/{types-DWpF5HiT.js → types-D5X7ikSD.js} +1 -1
  97. package/dist/assets/{useAddCell-CklpKCq2.js → useAddCell-bMoxoWAg.js} +1 -1
  98. package/dist/assets/{useBoolean-BE72e3yb.js → useBoolean-B1Xeh6vA.js} +1 -1
  99. package/dist/assets/{useCellActionButton-CQQ8dBy_.js → useCellActionButton-0MDUWMcl.js} +1 -1
  100. package/dist/assets/{useDeleteCell-BRWEDSc9.js → useDeleteCell-BUAQb9OH.js} +1 -1
  101. package/dist/assets/{useDependencyPanelTab-_ibASMD1.js → useDependencyPanelTab-odtlfdG2.js} +1 -1
  102. package/dist/assets/{useIframeCapabilities-BVYqWHmA.js → useIframeCapabilities-DuIDx9mD.js} +1 -1
  103. package/dist/assets/{useInstallPackage-Dxl_p6oW.js → useInstallPackage-Bdnnp5fe.js} +1 -1
  104. package/dist/assets/{useLifecycle-DvpL8DUJ.js → useLifecycle-ChNbzbYY.js} +1 -1
  105. package/dist/assets/useNotebookActions-CNWP5yqL.js +1 -0
  106. package/dist/assets/{useRunCells-jbEa8WGV.js → useRunCells-DyBzMbHe.js} +1 -1
  107. package/dist/assets/{useSplitCell-CdjW9REr.js → useSplitCell-CyFavQ2l.js} +1 -1
  108. package/dist/assets/{useTheme-CuyH5VNX.js → useTheme-DUdVAZI8.js} +1 -1
  109. package/dist/assets/{utilities.esm-eBoXu7lR.js → utilities.esm-j_F9mYkM.js} +1 -1
  110. package/dist/assets/{utils-CSDCHxwI.js → utils-DXvhzCGS.js} +1 -1
  111. package/dist/assets/{vega-component-B3_9VVhH.js → vega-component-Dd3MCYZO.js} +1 -1
  112. package/dist/assets/{write-secret-modal-DfRIeQB5.js → write-secret-modal-CpmU5gbF.js} +1 -1
  113. package/dist/index.html +67 -67
  114. package/package.json +1 -1
  115. package/src/components/editor/actions/useNotebookActions.tsx +5 -3
  116. package/src/components/editor/header/filename-form.tsx +15 -2
  117. package/src/components/editor/navigation/__tests__/navigation.test.ts +2 -0
  118. package/src/components/ui/progress.tsx +22 -5
  119. package/src/core/export/__tests__/hooks.test.ts +42 -19
  120. package/src/core/export/hooks.ts +33 -32
  121. package/src/core/saving/save-component.tsx +1 -0
  122. package/src/utils/__tests__/download.test.tsx +6 -4
  123. package/src/utils/__tests__/objects.test.ts +263 -0
  124. package/src/utils/__tests__/progress.test.ts +156 -0
  125. package/src/utils/download.ts +7 -2
  126. package/src/utils/objects.ts +3 -0
  127. package/src/utils/progress.ts +61 -0
  128. package/src/utils/toast-progress.tsx +41 -0
  129. package/dist/assets/download-kUMZIq8-.js +0 -1
  130. package/dist/assets/hooks-DxXRX-38.js +0 -1
  131. package/dist/assets/hotkeys-DghjL7BQ.js +0 -1
  132. package/dist/assets/index-CodxHczV.css +0 -2
  133. package/dist/assets/links-CUKo4afc.js +0 -1
  134. package/dist/assets/useNotebookActions-REoVp8xc.js +0 -1
@@ -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<Record<CellId, ["image/png", string]>> => {
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 results = await Promise.all(
133
- cellsToCaptureScreenshot.map(async ([cellId]) => {
134
- try {
135
- const dataUrl = await getImageDataUrlForCell(cellId, false);
136
- if (!dataUrl) {
137
- Logger.error(`Failed to capture screenshot for cell ${cellId}`);
138
- return null;
139
- }
140
- return [cellId, ["image/png", dataUrl]] as [
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
- return Objects.fromEntries(
152
- results.filter(
153
- (result): result is [CellId, ["image/png", string]] => result !== null,
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
- takeScreenshots: () => Promise<Record<CellId, ["image/png", string]>>,
164
- updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>,
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 (Object.keys(cellIdsToOutput).length > 0) {
168
+ const cellIdsToOutput = await takeScreenshots(progress);
169
+ if (Objects.size(cellIdsToOutput) > 0) {
169
170
  await updateCellOutputs({ cellIdsToOutput });
170
171
  }
171
172
  } catch (error) {
@@ -190,6 +190,7 @@ export function useSaveNotebook() {
190
190
  return {
191
191
  saveOrNameNotebook,
192
192
  saveIfNotebookIsPersistent,
193
+ saveNotebook,
193
194
  };
194
195
  }
195
196
 
@@ -57,10 +57,12 @@ describe("withLoadingToast", () => {
57
57
  });
58
58
 
59
59
  expect(toast).toHaveBeenCalledTimes(1);
60
- expect(toast).toHaveBeenCalledWith({
61
- title: "Loading...",
62
- duration: Infinity,
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
+ });
@@ -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) {
@@ -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
+ }