@marimo-team/islands 0.25.3-dev169 → 0.25.3-dev170

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 (123) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
  2. package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
  3. package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
  5. package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
  6. package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
  12. package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
  13. package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
  14. package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
  21. package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
  24. package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
  25. package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
  26. package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
  27. package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
  28. package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
  29. package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
  30. package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
  31. package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
  34. package/dist/main.js +187 -188
  35. package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
  37. package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
  40. package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
  41. package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
  43. package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
  45. package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
  52. package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
  53. package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
  54. package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
  55. package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
  56. package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
  57. package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
  58. package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
  59. package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
  60. package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
  61. package/package.json +1 -1
  62. package/src/__mocks__/requests.ts +0 -1
  63. package/src/components/editor/cell/StagedAICell.tsx +10 -2
  64. package/src/components/editor/cell/code/cell-editor.tsx +7 -14
  65. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
  66. package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
  67. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  68. package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
  69. package/src/core/ai/staged-cells.ts +7 -2
  70. package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
  71. package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
  72. package/src/core/cells/__tests__/cells.test.ts +0 -297
  73. package/src/core/cells/__tests__/document.test.ts +410 -0
  74. package/src/core/cells/__tests__/flush-document.test.ts +40 -0
  75. package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
  76. package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
  77. package/src/core/cells/cells.ts +27 -158
  78. package/src/core/cells/columns.ts +38 -0
  79. package/src/core/cells/document.ts +360 -0
  80. package/src/core/cells/types.ts +12 -0
  81. package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
  82. package/src/core/codemirror/cells/extensions.ts +14 -0
  83. package/src/core/codemirror/format.ts +6 -5
  84. package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
  85. package/src/core/codemirror/language/cell-code.ts +114 -0
  86. package/src/core/codemirror/language/extension.ts +47 -30
  87. package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
  88. package/src/core/codemirror/language/utils.ts +9 -39
  89. package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
  90. package/src/core/codemirror/rtc/extension.ts +0 -1
  91. package/src/core/edit-app.tsx +4 -3
  92. package/src/core/export/layout.ts +2 -2
  93. package/src/core/islands/bridge.ts +0 -1
  94. package/src/core/network/requests-lazy.ts +0 -1
  95. package/src/core/network/requests-network.ts +0 -9
  96. package/src/core/network/requests-static.ts +0 -1
  97. package/src/core/network/requests-toasting.tsx +0 -1
  98. package/src/core/network/types.ts +0 -5
  99. package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
  100. package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
  101. package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
  102. package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
  103. package/src/core/notebook/document/__tests__/model.test.ts +71 -15
  104. package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
  105. package/src/core/notebook/document/codemirror.ts +27 -122
  106. package/src/core/notebook/document/hooks.tsx +2 -2
  107. package/src/core/notebook/document/index.ts +9 -1
  108. package/src/core/notebook/document/model.ts +74 -24
  109. package/src/core/notebook/document/sync.ts +53 -4
  110. package/src/core/run-app.tsx +44 -41
  111. package/src/core/saving/save-component.tsx +2 -5
  112. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
  113. package/src/core/session/active-notebook.ts +4 -0
  114. package/src/core/session/useNotebookSession.tsx +39 -19
  115. package/src/core/wasm/__tests__/bridge.test.ts +3 -3
  116. package/src/core/wasm/bridge.ts +5 -12
  117. package/src/core/wasm/worker/worker.ts +0 -9
  118. package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
  119. package/src/utils/createReducer.ts +42 -21
  120. package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
  121. package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
  122. package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
  123. package/src/core/cells/document-changes.ts +0 -944
@@ -1,11 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { historyField } from "@codemirror/commands";
4
- import { dequal as isEqual } from "dequal";
5
4
  import { type Atom, atom, useAtom, useAtomValue } from "jotai";
6
5
  import { atomFamily, selectAtom, splitAtom } from "jotai/utils";
7
6
  import { createRef, type ReducerWithoutAction } from "react";
8
7
  import type { CellHandle } from "@/components/editor/notebook-cell";
8
+ import type { EditableDocument } from "@/core/notebook/document";
9
9
  import type { CollapsibleTree } from "@/utils/id-tree";
10
10
  import {
11
11
  type CellColumnId,
@@ -20,20 +20,17 @@ import { extractAllTracebackInfo, type TracebackInfo } from "@/utils/traceback";
20
20
  import { createReducerAndAtoms } from "../../utils/createReducer";
21
21
  import { foldAllBulk, unfoldAllBulk } from "../codemirror/editing/commands";
22
22
  import { editorMountScheduler } from "../codemirror/editor-mount-scheduler";
23
- import {
24
- splitEditor,
25
- updateEditorCodeFromPython,
26
- } from "../codemirror/language/utils";
23
+ import { prepareCellCodeUpdate } from "../codemirror/language/cell-code";
24
+ import { splitEditor } from "../codemirror/language/utils";
27
25
  import type { SerializedEditorState } from "../codemirror/types";
28
26
  import { findCollapseRange, mergeOutlines } from "../dom/outline";
29
27
  import type { CellMessage } from "../kernel/messages";
30
28
  import { isErrorMime } from "../mime";
31
29
  import type { CellConfig } from "../network/types";
32
- import { canUseRtc, isRtcEnabled } from "../rtc/state";
33
30
  import { createDeepEqualAtom, store } from "../state/jotai";
34
31
  import { isWasm } from "../wasm/utils";
35
32
  import { prepareCellForExecution, transitionCell } from "./cell";
36
- import { documentTransactionMiddleware } from "./document-changes";
33
+ import { applyDocument, documentEditMiddleware } from "./document";
37
34
  import { CellId, SCRATCH_CELL_ID, SETUP_CELL_ID } from "./ids";
38
35
  import { type CellLog, getCellLogsForMessage } from "./logs";
39
36
  import {
@@ -939,158 +936,19 @@ const {
939
936
  cellLogs: [...state.cellLogs, ...action.logs],
940
937
  };
941
938
  },
942
- setCellIds: (state, action: { cellIds: CellId[] }) => {
943
- const isTheSame = isEqual(state.cellIds.inOrderIds, action.cellIds);
944
- if (isTheSame) {
945
- return state;
946
- }
947
-
948
- // Create new cell data and runtime states for the new cell IDs
949
- const nextCellData = { ...state.cellData };
950
- const nextCellRuntime = { ...state.cellRuntime };
951
- const nextCellHandles = { ...state.cellHandles };
952
-
953
- for (const cellId of action.cellIds) {
954
- if (!(cellId in state.cellData)) {
955
- nextCellData[cellId] = createCell({ id: cellId });
956
- }
957
- if (!(cellId in state.cellRuntime)) {
958
- nextCellRuntime[cellId] = createCellRuntimeState();
959
- }
960
- if (!(cellId in state.cellHandles)) {
961
- nextCellHandles[cellId] = createRef();
962
- }
963
- }
964
-
965
- return {
966
- ...state,
967
- cellIds: MultiColumn.fromWithPreviousShape(action.cellIds, state.cellIds),
968
- cellData: nextCellData,
969
- cellRuntime: nextCellRuntime,
970
- cellHandles: nextCellHandles,
971
- };
972
- },
973
939
  /**
974
- * Rebuild the MultiColumn tree using each cell's `config.column` value.
940
+ * Follow the host document's cells and order.
975
941
  *
976
- * Used after a transaction whose `set-config` changes updated cells'
977
- * column metadata without physically moving them in the tree. Cells with
978
- * `config.column == null` inherit the column of the previous cell in the
979
- * given order (see `MultiColumn.fromIdsAndColumns`), which lets the server
980
- * send column changes only at column boundaries.
942
+ * Dispatched without middleware, since the document already has these
943
+ * changes.
981
944
  */
982
- rebuildCellColumns: (state, action: { cellIds: CellId[] }) => {
983
- const newCellIds = MultiColumn.fromIdsAndColumns(
984
- action.cellIds.map((id) => [
985
- id,
986
- state.cellData[id]?.config.column ?? null,
987
- ]),
988
- );
989
- return { ...state, cellIds: newCellIds };
990
- },
991
- setCellCodes: (
992
- state,
993
- action: {
994
- codes: string[];
995
- ids: CellId[];
996
- codeIsStale: boolean;
997
- names?: string[];
998
- configs?: CellConfig[];
999
- },
1000
- ) => {
1001
- invariant(
1002
- action.codes.length === action.ids.length,
1003
- "Expected codes and ids to have the same length",
1004
- );
1005
-
1006
- let nextState = { ...state };
1007
-
1008
- const cellReducer = ({
1009
- cell,
1010
- code,
1011
- cellId,
1012
- name,
1013
- config,
1014
- }: {
1015
- cell: CellData | undefined;
1016
- code: string;
1017
- cellId: CellId;
1018
- name?: string;
1019
- config?: CellConfig;
1020
- }) => {
1021
- if (!cell) {
1022
- return createCell({
1023
- id: cellId,
1024
- code,
1025
- name: name,
1026
- config: config,
1027
- lastCodeRun: action.codeIsStale ? null : code,
1028
- edited: action.codeIsStale && code.trim().length > 0,
1029
- });
1030
- }
1031
-
1032
- // If code is stale, we don't promote it to lastCodeRun
1033
- const lastCodeRun = action.codeIsStale ? cell.lastCodeRun : code;
1034
-
1035
- // Mark as edited if the code has changed
1036
- const edited = lastCodeRun
1037
- ? lastCodeRun.trim() !== code.trim()
1038
- : Boolean(code);
1039
-
1040
- // No change
1041
- if (cell.code.trim() === code.trim()) {
1042
- return {
1043
- ...cell,
1044
- code: code,
1045
- edited,
1046
- lastCodeRun,
1047
- ...(name !== undefined && { name }),
1048
- ...(config !== undefined && { config }),
1049
- };
1050
- }
1051
-
1052
- // Update codemirror if mounted. RTC-backed cells sync via Loro; others
1053
- // (e.g. scratchpad) still need an explicit editor update.
1054
- if (!isRtcEnabled() || !canUseRtc(cellId)) {
1055
- const cellHandle = nextState.cellHandles[cellId].current;
1056
- if (cellHandle?.editorViewOrNull) {
1057
- updateEditorCodeFromPython(cellHandle.editorViewOrNull, code);
1058
- }
1059
- }
1060
-
1061
- return {
1062
- ...cell,
1063
- code: code,
1064
- edited,
1065
- lastCodeRun,
1066
- ...(name !== undefined && { name }),
1067
- ...(config !== undefined && { config }),
1068
- };
1069
- };
1070
-
1071
- for (let i = 0; i < action.ids.length; i++) {
1072
- const cellId = action.ids[i];
1073
- const code = action.codes[i];
1074
- if (cellId === undefined || code === undefined) {
1075
- continue;
945
+ reconcileDocument: (state, document: EditableDocument) => {
946
+ const nextState = applyDocument(state, document);
947
+ for (const cellId of state.cellIds.inOrderIds) {
948
+ if (!(cellId in nextState.cellData)) {
949
+ releaseCellAtoms(cellId);
1076
950
  }
1077
- const name = action.names?.[i];
1078
- const config = action.configs?.[i];
1079
- nextState = {
1080
- ...nextState,
1081
- cellData: {
1082
- ...nextState.cellData,
1083
- [cellId]: cellReducer({
1084
- cell: nextState.cellData[cellId],
1085
- code,
1086
- cellId,
1087
- name,
1088
- config,
1089
- }),
1090
- },
1091
- };
1092
951
  }
1093
-
1094
952
  return nextState;
1095
953
  },
1096
954
  setStdinResponse: (
@@ -1440,7 +1298,14 @@ const {
1440
1298
  cellHandle.editorView,
1441
1299
  );
1442
1300
 
1443
- updateEditorCodeFromPython(cellHandle.editorView, beforeCursorCode);
1301
+ const update = prepareCellCodeUpdate(
1302
+ cellHandle.editorView.state,
1303
+ beforeCursorCode,
1304
+ );
1305
+
1306
+ if (update) {
1307
+ cellHandle.editorView.dispatch(update);
1308
+ }
1444
1309
 
1445
1310
  const newCellId = CellId.create();
1446
1311
 
@@ -1488,7 +1353,11 @@ const {
1488
1353
  return state;
1489
1354
  }
1490
1355
 
1491
- updateEditorCodeFromPython(cellHandle.editorView, snapshot);
1356
+ const update = prepareCellCodeUpdate(cellHandle.editorView.state, snapshot);
1357
+
1358
+ if (update) {
1359
+ cellHandle.editorView.dispatch(update);
1360
+ }
1492
1361
 
1493
1362
  return {
1494
1363
  ...state,
@@ -1599,9 +1468,9 @@ const {
1599
1468
  });
1600
1469
 
1601
1470
  // We apply the middleware here (rather than inline in createReducerAndAtoms)
1602
- // so that the document transaction middleware can import CellActions and
1471
+ // so that the document edit middleware can import CellActions and
1603
1472
  // strictly type the dispatched actions without creating a circular dependency.
1604
- addMiddleware(documentTransactionMiddleware);
1473
+ addMiddleware(documentEditMiddleware);
1605
1474
 
1606
1475
  function isCellCodeHidden(state: NotebookState, cellId: CellId): boolean {
1607
1476
  return (
@@ -0,0 +1,38 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /** How a document's cells fall into columns. */
4
+
5
+ import type { EditableDocument } from "@/core/notebook/document";
6
+ import type { CellId } from "./ids";
7
+
8
+ /**
9
+ * Each cell's column: its `column` config, or the column of the cell before
10
+ * it. The first cell starts in column 0.
11
+ */
12
+ export function resolveColumns(
13
+ document: EditableDocument,
14
+ ): Map<CellId, number> {
15
+ const columns = new Map<CellId, number>();
16
+ let current = 0;
17
+ for (const id of document.order) {
18
+ const column = document.cells.get(id)?.config.column;
19
+ if (column != null && column >= 0) {
20
+ current = column;
21
+ }
22
+ // The protocol's ids are plain strings; the notebook brands them.
23
+ columns.set(id as CellId, current);
24
+ }
25
+ return columns;
26
+ }
27
+
28
+ /** The cells of each column in order, with empty columns for gaps. */
29
+ export function groupByColumn(document: EditableDocument): CellId[][] {
30
+ const columns: CellId[][] = [[]];
31
+ for (const [id, column] of resolveColumns(document)) {
32
+ while (columns.length <= column) {
33
+ columns.push([]);
34
+ }
35
+ columns[column].push(id);
36
+ }
37
+ return columns;
38
+ }
@@ -0,0 +1,360 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Keep the notebook state and the host document in step while cells live
5
+ * in both.
6
+ *
7
+ * TODO(MO-8028): Delete this module once components read cells from the
8
+ * document model and `notebookAtom` holds only runtime and editor state.
9
+ */
10
+
11
+ import type * as Protocol from "@marimo-team/client/protocol";
12
+ import { dequal as isEqual } from "dequal";
13
+ import { createRef, type RefObject } from "react";
14
+ import type { CellHandle } from "@/components/editor/notebook-cell";
15
+ import type {
16
+ DocumentEdit,
17
+ DocumentEditor,
18
+ EditableCell,
19
+ EditableDocument,
20
+ } from "@/core/notebook/document";
21
+ import type { DispatchedActionOf } from "@/utils/createReducer";
22
+ import { CollapsibleTree, MultiColumn } from "@/utils/id-tree";
23
+ import { prepareCellCodeUpdate } from "../codemirror/language/cell-code";
24
+ import type { CellConfig } from "../network/types";
25
+ import { canUseRtc, isRtcEnabled } from "../rtc/state";
26
+ import { store } from "../state/jotai";
27
+ import { activeDocumentModelAtom } from "../session/active-notebook";
28
+ import type { CellActions, NotebookState } from "./cells";
29
+ import { groupByColumn } from "./columns";
30
+ import type { CellId } from "./ids";
31
+ import {
32
+ type CellData,
33
+ createCell,
34
+ createCellConfig,
35
+ createCellRuntimeState,
36
+ isEdited,
37
+ } from "./types";
38
+
39
+ type CellAction = DispatchedActionOf<CellActions>;
40
+
41
+ // Following the document ----------------------------------------------------
42
+
43
+ /** Rebuild the tree for `columns`, keeping column ids and collapsed sections. */
44
+ function reconcileColumns(
45
+ current: MultiColumn<CellId>,
46
+ columns: CellId[][],
47
+ ): MultiColumn<CellId> {
48
+ const previous = current.getColumns();
49
+ // The document cannot hold an empty column, so keep the ones this client has.
50
+ while (previous[columns.length]?.length === 0) {
51
+ columns.push([]);
52
+ }
53
+ if (
54
+ columns.length === previous.length &&
55
+ columns.every((ids, index) => isEqual(ids, previous[index].inOrderIds))
56
+ ) {
57
+ return current;
58
+ }
59
+ return new MultiColumn(
60
+ columns.map((ids, index) =>
61
+ CollapsibleTree.fromWithPreviousShape(ids, previous[index]),
62
+ ),
63
+ );
64
+ }
65
+
66
+ /** The cell's data with the document's fields, and its editor brought along. */
67
+ function followCell(
68
+ current: CellData,
69
+ cell: EditableCell,
70
+ handle: RefObject<CellHandle | null> | undefined,
71
+ ): CellData {
72
+ // Withheld source keeps the code this client has.
73
+ const code = cell.code ?? current.code;
74
+ const config = isEqual(current.config, cell.config)
75
+ ? current.config
76
+ : createCellConfig(cell.config);
77
+ if (
78
+ code === current.code &&
79
+ cell.name === current.name &&
80
+ config === current.config
81
+ ) {
82
+ return current;
83
+ }
84
+ if (code === current.code) {
85
+ return { ...current, name: cell.name, config };
86
+ }
87
+ // RTC cells follow Loro. Other editors take the host's code; one that
88
+ // shows it already, as the editor this client types in does, is left alone.
89
+ if (!isRtcEnabled() || !canUseRtc(current.id)) {
90
+ const view = handle?.current?.editorViewOrNull;
91
+ const update = view && prepareCellCodeUpdate(view.state, code);
92
+ if (update) {
93
+ view.dispatch(update);
94
+ }
95
+ }
96
+ return {
97
+ ...current,
98
+ code,
99
+ name: cell.name,
100
+ config,
101
+ edited: isEdited(current.lastCodeRun, code),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Make the notebook's cells and order follow the document.
107
+ *
108
+ * Returns the same state when nothing differs, so a local edit that the
109
+ * document already reflects changes nothing.
110
+ *
111
+ * TODO(MO-8028): Delete with `reconcileDocument` once cells are read from
112
+ * the document model.
113
+ */
114
+ export function applyDocument(
115
+ state: NotebookState,
116
+ document: EditableDocument,
117
+ ): NotebookState {
118
+ const cellIds = reconcileColumns(state.cellIds, groupByColumn(document));
119
+ let { cellData, cellRuntime, cellHandles } = state;
120
+ for (const id of cellIds.inOrderIds) {
121
+ const cell = document.cells.get(id);
122
+ const current = state.cellData[id];
123
+ if (cell === undefined) {
124
+ continue;
125
+ }
126
+ if (current === undefined) {
127
+ // A new cell has no run to be edited from.
128
+ cellData = {
129
+ ...cellData,
130
+ [id]: createCell({
131
+ id,
132
+ code: cell.code ?? "",
133
+ name: cell.name,
134
+ config: createCellConfig(cell.config),
135
+ }),
136
+ };
137
+ cellRuntime = { ...cellRuntime, [id]: createCellRuntimeState() };
138
+ cellHandles = { ...cellHandles, [id]: createRef() };
139
+ continue;
140
+ }
141
+ const next = followCell(current, cell, state.cellHandles[id]);
142
+ if (next !== current) {
143
+ cellData = { ...cellData, [id]: next };
144
+ }
145
+ }
146
+ const present = new Set(cellIds.inOrderIds);
147
+ const removed = state.cellIds.inOrderIds.filter((id) => !present.has(id));
148
+ if (removed.length > 0) {
149
+ const gone = new Set(removed);
150
+ const keep = <V>(record: Record<CellId, V>): Record<CellId, V> =>
151
+ Object.fromEntries(
152
+ Object.entries(record).filter(([id]) => !gone.has(id as CellId)),
153
+ ) as Record<CellId, V>;
154
+ cellData = keep(cellData);
155
+ cellRuntime = keep(cellRuntime);
156
+ cellHandles = keep(cellHandles);
157
+ }
158
+ if (
159
+ cellIds === state.cellIds &&
160
+ cellData === state.cellData &&
161
+ removed.length === 0
162
+ ) {
163
+ return state;
164
+ }
165
+ return { ...state, cellIds, cellData, cellRuntime, cellHandles };
166
+ }
167
+
168
+ // Editing the document ------------------------------------------------------
169
+
170
+ const CONFIG_FIELDS = ["disabled", "hide_code", "expand_output"] as const;
171
+
172
+ /** The fields of `after` that differ from `before`, leaving `column` to the tree. */
173
+ function configPatch(
174
+ before: CellConfig,
175
+ after: CellConfig,
176
+ ): Partial<Protocol.CellConfig> | null {
177
+ const patch: Partial<Protocol.CellConfig> = {};
178
+ for (const field of CONFIG_FIELDS) {
179
+ if (before[field] !== after[field]) {
180
+ patch[field] = after[field] ?? false;
181
+ }
182
+ }
183
+ return Object.keys(patch).length === 0 ? null : patch;
184
+ }
185
+
186
+ /**
187
+ * The `column` configs that make the document's columns match the tree's.
188
+ *
189
+ * A cell without a column follows the cell before it, so only cells whose
190
+ * resolved column differs from their position need one.
191
+ */
192
+ function columnFixes(state: NotebookState): Array<[CellId, number | null]> {
193
+ const fixes: Array<[CellId, number | null]> = [];
194
+ const columns = state.cellIds.getColumns();
195
+ if (columns.length === 1) {
196
+ for (const id of columns[0].inOrderIds) {
197
+ const column = state.cellData[id].config.column;
198
+ if (column != null && column !== 0) {
199
+ fixes.push([id, null]);
200
+ }
201
+ }
202
+ return fixes;
203
+ }
204
+ let inherited = 0;
205
+ columns.forEach((column, target) => {
206
+ for (const id of column.inOrderIds) {
207
+ const configured = state.cellData[id].config.column;
208
+ const resolved =
209
+ configured != null && configured >= 0 ? configured : inherited;
210
+ if (resolved !== target) {
211
+ fixes.push([id, target]);
212
+ }
213
+ inherited = target;
214
+ }
215
+ });
216
+ return fixes;
217
+ }
218
+
219
+ /** The edit that makes the document match `next`, or null when it already does. */
220
+ function diffNotebook(
221
+ prev: NotebookState,
222
+ next: NotebookState,
223
+ ): DocumentEdit | null {
224
+ const steps: Array<(tx: DocumentEditor) => void> = [];
225
+ const prevOrder = prev.cellIds.inOrderIds;
226
+ const nextOrder = next.cellIds.inOrderIds;
227
+ const prevIds = new Set(prevOrder);
228
+ const nextIds = new Set(nextOrder);
229
+ for (const id of prevOrder) {
230
+ if (!nextIds.has(id)) {
231
+ steps.push((tx) => tx.deleteCell(id));
232
+ }
233
+ }
234
+ const created: CellId[] = [];
235
+ for (const id of nextOrder) {
236
+ const cell = next.cellData[id];
237
+ const before = prev.cellData[id];
238
+ if (!prevIds.has(id) || before === undefined) {
239
+ created.push(id);
240
+ steps.push((tx) =>
241
+ tx.createCell({
242
+ id,
243
+ code: cell.code,
244
+ name: cell.name,
245
+ config: cell.config,
246
+ }),
247
+ );
248
+ continue;
249
+ }
250
+ if (before === cell) {
251
+ continue;
252
+ }
253
+ if (before.code !== cell.code) {
254
+ steps.push((tx) => tx.setCode(id, cell.code));
255
+ }
256
+ if (before.name !== cell.name) {
257
+ steps.push((tx) => tx.setName(id, cell.name));
258
+ }
259
+ const patch = configPatch(before.config, cell.config);
260
+ if (patch !== null) {
261
+ steps.push((tx) => tx.updateConfig(id, patch));
262
+ }
263
+ }
264
+ for (const [id, column] of columnFixes(next)) {
265
+ steps.push((tx) => tx.updateConfig(id, { column }));
266
+ }
267
+ // Created cells go to the end, so the order is set when they belong elsewhere.
268
+ const survivors = prevOrder.filter((id) => nextIds.has(id));
269
+ if (!isEqual(nextOrder, [...survivors, ...created])) {
270
+ steps.push((tx) => tx.setOrder(nextOrder));
271
+ }
272
+ if (steps.length === 0) {
273
+ return null;
274
+ }
275
+ return (tx) => {
276
+ for (const step of steps) {
277
+ step(tx);
278
+ }
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Middleware for the notebook reducer. Writes what an action changed about
284
+ * the cells and their order into the document model as one edit.
285
+ *
286
+ * TODO(MO-8028): Each action family that moves onto the model removes its
287
+ * part of `diffNotebook`. The middleware goes with the last one, code.
288
+ */
289
+ export function documentEditMiddleware(
290
+ prev: NotebookState,
291
+ next: NotebookState,
292
+ action: CellAction,
293
+ ): void {
294
+ // The kernel's cells replace the notebook rather than edit it.
295
+ if (action.type === "setCells") {
296
+ return;
297
+ }
298
+ if (prev.cellIds === next.cellIds && prev.cellData === next.cellData) {
299
+ return;
300
+ }
301
+ const model = store.get(activeDocumentModelAtom);
302
+ if (model === null) {
303
+ return;
304
+ }
305
+ const edit = diffNotebook(prev, next);
306
+ if (edit !== null) {
307
+ store.set(model.editAtom, edit);
308
+ }
309
+ }
310
+
311
+ // Commands that read the document on the host -------------------------------
312
+
313
+ /**
314
+ * Send this client's edits to the host before a command that reads them
315
+ * there. Without a document or a backend, as on a static page, there is
316
+ * nothing to send and the command proceeds.
317
+ */
318
+ export async function flushDocument(): Promise<void> {
319
+ const model = store.get(activeDocumentModelAtom);
320
+ if (
321
+ model === null ||
322
+ store.get(model.syncStatusAtom).status === "unavailable"
323
+ ) {
324
+ return;
325
+ }
326
+ await model.flush();
327
+ }
328
+
329
+ /**
330
+ * Run a legacy save after this client's edits reach the host, and keep
331
+ * later edits local until it finishes. The save carries the whole
332
+ * notebook and the host takes it as the document, so an edit sent
333
+ * meanwhile would be overwritten. When the edits cannot be sent the save
334
+ * still runs, and the model resumes against the saved document.
335
+ *
336
+ * TODO(MO-8028): Goes with the legacy save, along with the hold.
337
+ */
338
+ export async function withDocumentSave<T>(save: () => Promise<T>): Promise<T> {
339
+ const model = store.get(activeDocumentModelAtom);
340
+ if (model === null) {
341
+ return save();
342
+ }
343
+ let flushed = true;
344
+ try {
345
+ await model.flush();
346
+ } catch {
347
+ flushed = false;
348
+ }
349
+ const release = model.hold();
350
+ let result: T;
351
+ try {
352
+ result = await save();
353
+ } finally {
354
+ release();
355
+ }
356
+ if (!flushed) {
357
+ void model.retry().catch(() => undefined);
358
+ }
359
+ return result;
360
+ }
@@ -8,6 +8,18 @@ import type { CellId } from "./ids";
8
8
  import { DEFAULT_CELL_NAME } from "./names";
9
9
  import type { Outline } from "./outline";
10
10
 
11
+ /**
12
+ * Whether a cell's code has changed since it last ran. A cell that never ran
13
+ * is edited once it has code. Whitespace at the ends does not count.
14
+ *
15
+ * TODO(MO-8028): Temporary until staleness comes from the host's executions.
16
+ */
17
+ export function isEdited(lastCodeRun: string | null, code: string): boolean {
18
+ return lastCodeRun === null
19
+ ? code.trim().length > 0
20
+ : lastCodeRun.trim() !== code.trim();
21
+ }
22
+
11
23
  /**
12
24
  * Create a new cell with default state.
13
25
  */
@@ -11,7 +11,8 @@ import { formattingChangeEffect } from "../../format";
11
11
  import { loroSyncAnnotation } from "../../rtc/loro/sync";
12
12
  import { exportedForTesting } from "../extensions";
13
13
 
14
- const { shouldAutorunMarkdownUpdate } = exportedForTesting;
14
+ const { isCollaboratorUpdate, shouldAutorunMarkdownUpdate } =
15
+ exportedForTesting;
15
16
 
16
17
  function createTransaction(spec: TransactionSpec) {
17
18
  return EditorState.create({ doc: "" }).update(spec);
@@ -125,3 +126,28 @@ describe("shouldAutorunMarkdownUpdate", () => {
125
126
  ).toBe(false);
126
127
  });
127
128
  });
129
+
130
+ describe("isCollaboratorUpdate", () => {
131
+ it("recognizes an update made only of Loro sync transactions", () => {
132
+ const transaction = createTransaction({
133
+ changes: { from: 0, insert: "#" },
134
+ annotations: [loroSyncAnnotation.of(true)],
135
+ });
136
+
137
+ expect(isCollaboratorUpdate([transaction])).toBe(true);
138
+ });
139
+
140
+ it("treats typing beside a Loro sync transaction as local", () => {
141
+ const remote = createTransaction({
142
+ changes: { from: 0, insert: "#" },
143
+ annotations: [loroSyncAnnotation.of(true)],
144
+ });
145
+ const local = createTransaction({
146
+ changes: { from: 0, insert: "x" },
147
+ annotations: [Transaction.userEvent.of("input.type")],
148
+ });
149
+
150
+ expect(isCollaboratorUpdate([remote, local])).toBe(false);
151
+ expect(isCollaboratorUpdate([])).toBe(false);
152
+ });
153
+ });