@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
@@ -325,6 +325,11 @@ function cellKeymaps({
325
325
  function cellCodeEditing(hotkeys: HotkeyProvider): Extension[] {
326
326
  const onChangePlugin = EditorView.updateListener.of((update) => {
327
327
  if (update.docChanged) {
328
+ // Loro already applied a collaborator's change here. The client that
329
+ // typed it syncs it to the document.
330
+ if (isCollaboratorUpdate(update.transactions)) {
331
+ return;
332
+ }
328
333
  // Check if the doc update was a formatting change
329
334
  // e.g. changing from python to markdown
330
335
  const isFormattingChange = update.transactions.some((tr) =>
@@ -350,6 +355,14 @@ function cellCodeEditing(hotkeys: HotkeyProvider): Extension[] {
350
355
  return [onChangePlugin, formatKeymapExtension(hotkeys)];
351
356
  }
352
357
 
358
+ /** Whether every transaction in an update arrived from a collaborator through Loro. */
359
+ function isCollaboratorUpdate(transactions: readonly Transaction[]): boolean {
360
+ return (
361
+ transactions.length > 0 &&
362
+ transactions.every((tr) => tr.annotation(loroSyncAnnotation) !== undefined)
363
+ );
364
+ }
365
+
353
366
  const MARKDOWN_AUTORUN_USER_EVENTS = ["input", "delete", "undo", "redo"];
354
367
 
355
368
  function shouldAutorunMarkdownUpdate({
@@ -467,5 +480,6 @@ export function cellBundle({
467
480
  }
468
481
 
469
482
  export const exportedForTesting = {
483
+ isCollaboratorUpdate,
470
484
  shouldAutorunMarkdownUpdate,
471
485
  };
@@ -18,10 +18,8 @@ import { cellIdState } from "./config/extension";
18
18
  import { languageAdapterState } from "./language/extension";
19
19
  import { SCHEMA_CACHE } from "./language/languages/sql/completion-store";
20
20
  import { isKnownDialect } from "./language/languages/sql/utils";
21
- import {
22
- getEditorCodeAsPython,
23
- updateEditorCodeFromPython,
24
- } from "./language/utils";
21
+ import { prepareCellCodeUpdate } from "./language/cell-code";
22
+ import { getEditorCodeAsPython } from "./language/utils";
25
23
  import { replaceEditorContent } from "./replace-editor-content";
26
24
 
27
25
  export const formattingChangeEffect = StateEffect.define<boolean>();
@@ -62,7 +60,10 @@ export async function formatEditorViews(views: Record<CellId, EditorView>) {
62
60
  code: formattedCode,
63
61
  formattingChange: true,
64
62
  });
65
- updateEditorCodeFromPython(view, formattedCode);
63
+ const update = prepareCellCodeUpdate(view.state, formattedCode);
64
+ if (update) {
65
+ view.dispatch(update);
66
+ }
66
67
  }
67
68
  }
68
69
 
@@ -1,23 +1,35 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { EditorState } from "@codemirror/state";
3
+ import { history, redo, undo } from "@codemirror/commands";
4
+ import { EditorSelection, EditorState, StateEffect } from "@codemirror/state";
4
5
  import { EditorView } from "@codemirror/view";
5
- import { describe, expect, it } from "vitest";
6
+ import { afterEach, describe, expect, it } from "vitest";
6
7
  import { cellId } from "@/__tests__/branded";
7
8
  import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
8
9
  import { cellConfigExtension } from "../../config/extension";
9
- import { adaptiveLanguageConfiguration, switchLanguage } from "../extension";
10
+ import { prepareCellCodeUpdate } from "../cell-code";
11
+ import { languageMetadataField } from "../metadata";
10
12
  import {
11
- getEditorCodeAsPython,
12
- splitEditor,
13
- updateEditorCodeFromPython,
14
- } from "../utils";
13
+ adaptiveLanguageConfiguration,
14
+ languageAdapterState,
15
+ switchLanguage,
16
+ } from "../extension";
17
+ import { getEditorCodeAsPython, splitEditor } from "../utils";
18
+
19
+ const views: EditorView[] = [];
20
+
21
+ afterEach(() => {
22
+ for (const view of views.splice(0)) {
23
+ view.destroy();
24
+ }
25
+ });
15
26
 
16
27
  function createEditor(doc: string) {
17
- return new EditorView({
28
+ const view = new EditorView({
18
29
  state: EditorState.create({
19
30
  doc,
20
31
  extensions: [
32
+ history(),
21
33
  adaptiveLanguageConfiguration({
22
34
  completionConfig: {
23
35
  activate_on_typing: true,
@@ -50,6 +62,8 @@ function createEditor(doc: string) {
50
62
  ],
51
63
  }),
52
64
  });
65
+ views.push(view);
66
+ return view;
53
67
  }
54
68
 
55
69
  describe("getEditorCodeAsPython", () => {
@@ -72,14 +86,172 @@ describe("getEditorCodeAsPython", () => {
72
86
  });
73
87
  });
74
88
 
75
- describe("updateEditorCodeFromPython", () => {
76
- it("should update the editor code with the provided Python code", () => {
77
- const mockEditor = createEditor("");
78
- const pythonCode = "print('Hello, World!')";
79
- const result = updateEditorCodeFromPython(mockEditor, pythonCode);
80
- expect(result).toEqual(pythonCode);
81
- expect(mockEditor.state.doc.toString()).toEqual(pythonCode);
89
+ describe("prepareCellCodeUpdate transactions", () => {
90
+ it("applies text and metadata together, including metadata-only undo", () => {
91
+ const view = createEditor('mo.md(r"hi")');
92
+ switchLanguage(view, { language: "markdown" });
93
+ const observed: Array<{ text: string; prefix: string }> = [];
94
+ view.dispatch({
95
+ effects: StateEffect.appendConfig.of(
96
+ EditorView.updateListener.of((update) => {
97
+ if (
98
+ update.transactions.some(
99
+ (tr) => tr.effects.length > 0 || tr.docChanged,
100
+ )
101
+ ) {
102
+ observed.push({
103
+ text: update.state.doc.toString(),
104
+ prefix: update.state.field(languageMetadataField).quotePrefix,
105
+ });
106
+ }
107
+ }),
108
+ ),
109
+ });
110
+ observed.length = 0;
111
+
112
+ view.dispatch(prepareCellCodeUpdate(view.state, 'mo.md(f"hi")')!);
113
+ expect(observed).toEqual([{ text: "hi", prefix: "f" }]);
114
+ expect(undo(view)).toBe(true);
115
+ expect(view.state.field(languageMetadataField).quotePrefix).toBe("r");
116
+ expect(redo(view)).toBe(true);
117
+ expect(view.state.field(languageMetadataField).quotePrefix).toBe("f");
118
+ });
119
+
120
+ it("clears incompatible history while applying a language fallback atomically", () => {
121
+ const view = createEditor('mo.md("hi")');
122
+ switchLanguage(view, { language: "markdown" });
123
+ view.dispatch({ changes: { from: 2, insert: "!" } });
124
+ const observed: Array<{ text: string; language: string }> = [];
125
+ view.dispatch({
126
+ effects: StateEffect.appendConfig.of(
127
+ EditorView.updateListener.of((update) => {
128
+ observed.push({
129
+ text: update.state.doc.toString(),
130
+ language: update.state.field(languageAdapterState).type,
131
+ });
132
+ }),
133
+ ),
134
+ });
135
+ observed.length = 0;
136
+
137
+ view.dispatch(prepareCellCodeUpdate(view.state, "x = 1")!);
138
+ expect(observed).toEqual([{ text: "x = 1", language: "python" }]);
139
+ expect(undo(view)).toBe(false);
140
+ view.dispatch({ changes: { from: 5, insert: "0" } });
141
+ expect(undo(view)).toBe(true);
142
+ expect(view.state.doc.toString()).toBe("x = 1");
82
143
  });
144
+
145
+ it("maps the selection through separate edits and keeps the update undoable", () => {
146
+ const view = createEditor("a = 1\nb = 2\nc = 3");
147
+ view.dispatch({ selection: EditorSelection.range(6, 11) });
148
+ view.dispatch(
149
+ prepareCellCodeUpdate(view.state, "alpha = 1\nb = 2\ncharlie = 3")!,
150
+ );
151
+ expect(
152
+ view.state.sliceDoc(
153
+ view.state.selection.main.from,
154
+ view.state.selection.main.to,
155
+ ),
156
+ ).toBe("b = 2");
157
+ expect(undo(view)).toBe(true);
158
+ expect(view.state.doc.toString()).toBe("a = 1\nb = 2\nc = 3");
159
+ });
160
+
161
+ it.each(["mo.md()", 'mo.md("hi")', 'df = mo.sql("SELECT 1")'])(
162
+ "keeps Python mode when receiving %s",
163
+ (code) => {
164
+ const view = createEditor("mo.md");
165
+ view.dispatch(prepareCellCodeUpdate(view.state, code)!);
166
+ expect({
167
+ language: view.state.field(languageAdapterState).type,
168
+ code: view.state.doc.toString(),
169
+ }).toEqual({ language: "python", code });
170
+ },
171
+ );
172
+
173
+ it.each([
174
+ {
175
+ language: "markdown",
176
+ initial: 'mo.md("hi")',
177
+ next: 'mo.md("bye")',
178
+ text: "bye",
179
+ },
180
+ {
181
+ language: "sql",
182
+ initial: 'df = mo.sql("SELECT 1")',
183
+ next: 'df = mo.sql("SELECT 2")',
184
+ text: "SELECT 2",
185
+ },
186
+ ] as const)(
187
+ "preserves compatible $language mode",
188
+ ({ language, initial, next, text }) => {
189
+ const view = createEditor(initial);
190
+ switchLanguage(view, { language });
191
+ view.dispatch(prepareCellCodeUpdate(view.state, next)!);
192
+ expect({
193
+ language: view.state.field(languageAdapterState).type,
194
+ code: view.state.doc.toString(),
195
+ }).toEqual({ language, code: text });
196
+ },
197
+ );
198
+
199
+ it.each([
200
+ {
201
+ language: "markdown",
202
+ initial: 'mo.md("hi")',
203
+ next: 'df = mo.sql("SELECT 1")',
204
+ },
205
+ {
206
+ language: "sql",
207
+ initial: 'df = mo.sql("SELECT 1")',
208
+ next: 'mo.md("hi")',
209
+ },
210
+ { language: "markdown", initial: 'mo.md("hi")', next: "x = 1" },
211
+ ] as const)(
212
+ "falls back from $language to Python for $next",
213
+ ({ language, initial, next }) => {
214
+ const view = createEditor(initial);
215
+ switchLanguage(view, { language });
216
+ view.dispatch(prepareCellCodeUpdate(view.state, next)!);
217
+ expect({
218
+ language: view.state.field(languageAdapterState).type,
219
+ code: view.state.doc.toString(),
220
+ }).toEqual({ language: "python", code: next });
221
+ },
222
+ );
223
+ });
224
+
225
+ describe("prepareCellCodeUpdate", () => {
226
+ it("does not normalize an editor that already represents the source", () => {
227
+ const view = createEditor('mo.md("hi")');
228
+ switchLanguage(view, { language: "markdown" });
229
+ expect(prepareCellCodeUpdate(view.state, 'mo.md("hi")')).toBeNull();
230
+ view.dispatch({ changes: { from: 2, insert: "\n " } });
231
+ const source = getEditorCodeAsPython(view);
232
+ expect(prepareCellCodeUpdate(view.state, source)).toBeNull();
233
+ expect(view.state.doc.toString()).toBe("hi\n ");
234
+ });
235
+
236
+ it.each(["", 'mo.md(f"hi {x}")'])(
237
+ "prepares initialization from %j without mutating the editor",
238
+ (initial) => {
239
+ const view = createEditor(initial);
240
+ const update = prepareCellCodeUpdate(view.state, 'mo.md(f"hi {x}")', {
241
+ initialize: true,
242
+ });
243
+ expect(view.state.doc.toString()).toBe(initial);
244
+ expect(view.state.field(languageAdapterState).type).toBe("python");
245
+ expect(update).not.toBeNull();
246
+ if (update) {
247
+ view.dispatch(update);
248
+ }
249
+ expect(view.state.doc.toString()).toBe("hi {x}");
250
+ expect(view.state.field(languageAdapterState).type).toBe("markdown");
251
+ expect(view.state.field(languageMetadataField).quotePrefix).toBe("f");
252
+ expect(undo(view)).toBe(false);
253
+ },
254
+ );
83
255
  });
84
256
 
85
257
  describe("splitEditor", () => {
@@ -0,0 +1,114 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { diff } from "@codemirror/merge";
4
+ import {
5
+ type EditorState,
6
+ type StateEffect,
7
+ Transaction,
8
+ type TransactionSpec,
9
+ } from "@codemirror/state";
10
+ import { suppressSignatureHelp } from "@marimo-team/codemirror-languageserver";
11
+ import { dequal } from "dequal";
12
+ import {
13
+ languageAdapterFromCode,
14
+ languageAdapterState,
15
+ languageConfigurationEffects,
16
+ } from "./extension";
17
+ import { LanguageAdapters } from "./LanguageAdapters";
18
+ import { sqlDialectEffect } from "./languages/sql/sql";
19
+ import { languageMetadataField, setLanguageMetadata } from "./metadata";
20
+
21
+ /** The Python source represented by the editor's text and language metadata. */
22
+ export function getCellCode(state: EditorState): string {
23
+ const text = state.doc.toString();
24
+ const adapter = state.field(languageAdapterState, false);
25
+ return adapter
26
+ ? adapter.transformOut(
27
+ text,
28
+ state.field(languageMetadataField, false) ?? {},
29
+ )[0]
30
+ : text;
31
+ }
32
+
33
+ /** Account for adapters normalizing quotes and whitespace when reading source. */
34
+ export function editorShowsCellCode(
35
+ state: EditorState,
36
+ source: string,
37
+ ): boolean {
38
+ if (getCellCode(state) === source) {
39
+ return true;
40
+ }
41
+ const adapter = state.field(languageAdapterState, false);
42
+ if (!adapter?.isSupported(source)) {
43
+ return false;
44
+ }
45
+ const [text, , metadata] = adapter.transformIn(source);
46
+ return (
47
+ text === state.doc.toString() &&
48
+ dequal(metadata, state.field(languageMetadataField, false))
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Prepare one update to the editor's text, language, and metadata.
54
+ * Existing editors keep a compatible mode; only initial loading infers one.
55
+ * The caller decides whether this is a local edit or a remote synchronization.
56
+ */
57
+ export function prepareCellCodeUpdate(
58
+ state: EditorState,
59
+ source: string,
60
+ { initialize = false }: { initialize?: boolean } = {},
61
+ ): TransactionSpec | null {
62
+ const current = state.field(languageAdapterState, false);
63
+ let target = current;
64
+ if (current) {
65
+ if (initialize) {
66
+ target = languageAdapterFromCode(source);
67
+ } else if (!current.isSupported(source)) {
68
+ target = LanguageAdapters.python;
69
+ }
70
+ }
71
+ const languageChanged = target?.type !== current?.type;
72
+ if (!languageChanged && editorShowsCellCode(state, source)) {
73
+ return null;
74
+ }
75
+
76
+ const effects: StateEffect<unknown>[] = [];
77
+ let text = source;
78
+ if (target) {
79
+ if (languageChanged) {
80
+ effects.push(...languageConfigurationEffects(state, target));
81
+ }
82
+ const [inner, , metadata] = target.transformIn(source);
83
+ text = inner;
84
+ const metadataChanged = !dequal(
85
+ metadata,
86
+ state.field(languageMetadataField, false),
87
+ );
88
+ if (metadataChanged) {
89
+ effects.push(setLanguageMetadata.of(metadata));
90
+ }
91
+ if (target.type === "sql" && (languageChanged || metadataChanged)) {
92
+ effects.push(sqlDialectEffect(metadata.engine));
93
+ }
94
+ }
95
+
96
+ // Match the merge view's scan limit so large rewrites stay bounded. The diff
97
+ // maps selections, folds, and diagnostics through each individual edit.
98
+ const changes = diff(state.doc.toString(), text, { scanLimit: 500 }).map(
99
+ (change) => ({
100
+ from: change.fromA,
101
+ to: change.toA,
102
+ insert: text.slice(change.fromB, change.toB),
103
+ }),
104
+ );
105
+ return {
106
+ changes,
107
+ effects,
108
+ annotations: [
109
+ suppressSignatureHelp.of(true),
110
+ // History was reset with the language; the previous text uses another representation.
111
+ ...(languageChanged ? [Transaction.addToHistory.of(false)] : []),
112
+ ],
113
+ };
114
+ }
@@ -1,11 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { history } from "@codemirror/commands";
3
+ import { history, historyField, invertedEffects } from "@codemirror/commands";
4
4
  import {
5
5
  Compartment,
6
6
  EditorSelection,
7
+ EditorState,
7
8
  StateEffect,
8
9
  StateField,
10
+ Transaction,
9
11
  } from "@codemirror/state";
10
12
  import { type EditorView, keymap, showPanel } from "@codemirror/view";
11
13
  import type { CellId } from "@/core/cells/ids";
@@ -25,11 +27,10 @@ import {
25
27
  placeholderState,
26
28
  } from "../config/extension";
27
29
  import type { PlaceholderType } from "../config/types";
28
- import { historyCompartment } from "../editing/extensions";
29
30
  import { formattingChangeEffect } from "../format";
30
31
  import { createPanel } from "../react-dom/createPanel";
31
32
  import { getLanguageAdapters, LanguageAdapters } from "./LanguageAdapters";
32
- import { initializeSQLDialect } from "./languages/sql/sql";
33
+ import { sqlDialectEffect } from "./languages/sql/sql";
33
34
  import type { LanguageMetadata } from "./metadata";
34
35
  import { languageMetadataField, setLanguageMetadata } from "./metadata";
35
36
  import { LanguagePanelComponent } from "./panel/panel";
@@ -42,6 +43,32 @@ import { getEditorCodeAsPython } from "./utils";
42
43
  */
43
44
  const languageCompartment = new Compartment();
44
45
 
46
+ /** Reconfigure a language and discard history written in its previous representation. */
47
+ export function languageConfigurationEffects(
48
+ state: EditorState,
49
+ language: LanguageAdapter,
50
+ ): StateEffect<unknown>[] {
51
+ const extensions = language.getExtension({
52
+ cellId: state.facet(cellIdState),
53
+ completionConfig: state.facet(completionConfigState),
54
+ hotkeys: state.facet(hotkeysProviderState),
55
+ placeholderType: state.facet(placeholderState),
56
+ lspConfig: state.facet(lspConfigState),
57
+ });
58
+ if (state.field(historyField, false) !== undefined) {
59
+ // A fresh initializer resets the history in the same transaction as the language.
60
+ extensions.push(
61
+ historyField.init(() =>
62
+ EditorState.create({ extensions: [history()] }).field(historyField),
63
+ ),
64
+ );
65
+ }
66
+ return [
67
+ setLanguageAdapter.of(language),
68
+ languageCompartment.reconfigure(extensions),
69
+ ];
70
+ }
71
+
45
72
  /**
46
73
  * State effect to set the language adapter.
47
74
  */
@@ -129,11 +156,6 @@ function updateLanguageAdapterAndCode({
129
156
  }) {
130
157
  const currentLanguage = view.state.field(languageAdapterState);
131
158
  const code = view.state.doc.toString();
132
- const completionConfig = view.state.facet(completionConfigState);
133
- const hotkeysProvider = view.state.facet(hotkeysProviderState);
134
- const placeholderType = view.state.facet(placeholderState);
135
- const cellId = view.state.facet(cellIdState);
136
- const lspConfig = view.state.facet(lspConfigState);
137
159
  let metadata = view.state.field(languageMetadataField);
138
160
  let cursor = view.state.selection.main.head;
139
161
 
@@ -164,22 +186,15 @@ function updateLanguageAdapterAndCode({
164
186
  // Update the state
165
187
  view.dispatch({
166
188
  effects: [
167
- setLanguageAdapter.of(nextLanguage),
189
+ ...languageConfigurationEffects(view.state, nextLanguage),
168
190
  setLanguageMetadata.of(metadata),
169
- languageCompartment.reconfigure(
170
- nextLanguage.getExtension({
171
- cellId,
172
- completionConfig,
173
- hotkeys: hotkeysProvider,
174
- placeholderType,
175
- lspConfig,
176
- }),
177
- ),
178
- // Clear history
179
- historyCompartment.reconfigure([]),
191
+ ...(nextLanguage.type === "sql"
192
+ ? [sqlDialectEffect(metadata.engine)]
193
+ : []),
180
194
  // Let downstream extensions know that this is a formatting change
181
195
  formattingChangeEffect.of(true),
182
196
  ],
197
+ annotations: Transaction.addToHistory.of(false),
183
198
  changes: opts.keepCodeAsIs
184
199
  ? undefined
185
200
  : {
@@ -189,16 +204,6 @@ function updateLanguageAdapterAndCode({
189
204
  },
190
205
  selection: opts.keepCodeAsIs ? undefined : EditorSelection.cursor(cursor),
191
206
  });
192
-
193
- // Add history back
194
- view.dispatch({
195
- effects: [historyCompartment.reconfigure([history()])],
196
- });
197
-
198
- // Initialize SQL dialect if switching to SQL
199
- if (nextLanguage.type === "sql") {
200
- initializeSQLDialect(view);
201
- }
202
207
  }
203
208
 
204
209
  /**
@@ -228,6 +233,18 @@ export function adaptiveLanguageConfiguration(opts: {
228
233
  ),
229
234
  languageAdapterState,
230
235
  languageMetadataField,
236
+ invertedEffects.of((tr) => {
237
+ if (!tr.effects.some((effect) => effect.is(setLanguageMetadata))) {
238
+ return [];
239
+ }
240
+ const metadata = tr.startState.field(languageMetadataField);
241
+ return [
242
+ setLanguageMetadata.of(metadata),
243
+ ...(tr.startState.field(languageAdapterState).type === "sql"
244
+ ? [sqlDialectEffect(metadata.engine)]
245
+ : []),
246
+ ];
247
+ }),
231
248
  ];
232
249
  }
233
250
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { acceptCompletion, autocompletion } from "@codemirror/autocomplete";
4
4
  import { insertTab } from "@codemirror/commands";
5
- import { type SQLDialect, type SQLNamespace, sql } from "@codemirror/lang-sql";
5
+ import { type SQLNamespace, sql } from "@codemirror/lang-sql";
6
6
  import type { EditorState, Extension } from "@codemirror/state";
7
7
  import { Compartment } from "@codemirror/state";
8
8
  import {
@@ -497,12 +497,12 @@ function isDuckDBConnection(
497
497
  }
498
498
 
499
499
  /**
500
- * Update the SQL dialect in the editor view.
500
+ * The effect that reconfigures the SQL language for the connection's dialect.
501
+ * Callers dispatch it, alone or with the change that selected the connection.
501
502
  */
502
- function updateSQLDialect(view: EditorView, dialect: SQLDialect) {
503
- view.dispatch({
504
- effects: sqlConfigCompartment.reconfigure(sql({ dialect })),
505
- });
503
+ export function sqlDialectEffect(connectionName: ConnectionName) {
504
+ const dialect = SCHEMA_CACHE.getDialect(connectionName);
505
+ return sqlConfigCompartment.reconfigure(sql({ dialect }));
506
506
  }
507
507
 
508
508
  // Helper functions to update the SQL dialect
@@ -511,17 +511,7 @@ export function updateSQLDialectFromConnection(
511
511
  view: EditorView,
512
512
  connectionName: ConnectionName,
513
513
  ) {
514
- const dialect = SCHEMA_CACHE.getDialect(connectionName);
515
- updateSQLDialect(view, dialect);
516
- }
517
-
518
- export function initializeSQLDialect(view: EditorView) {
519
- // Get current engine and update dialect
520
- const metadata = getSQLMetadata(view.state);
521
- const connectionName = metadata.engine;
522
- const dialect = SCHEMA_CACHE.getDialect(connectionName);
523
-
524
- updateSQLDialect(view, dialect);
514
+ view.dispatch({ effects: sqlDialectEffect(connectionName) });
525
515
  }
526
516
 
527
517
  function getSQLMetadata(state: EditorState): SQLLanguageAdapterMetadata {
@@ -2,12 +2,8 @@
2
2
 
3
3
  import type { EditorState } from "@codemirror/state";
4
4
  import type { EditorView } from "@codemirror/view";
5
- import { replaceEditorContent } from "../replace-editor-content";
6
- import {
7
- languageAdapterFromCode,
8
- languageAdapterState,
9
- switchLanguage,
10
- } from "./extension";
5
+ import { getCellCode } from "./cell-code";
6
+ import { languageAdapterState } from "./extension";
11
7
  import { languageMetadataField } from "./metadata";
12
8
 
13
9
  /**
@@ -19,42 +15,16 @@ export function getEditorCodeAsPython(
19
15
  fromPos?: number,
20
16
  toPos?: number,
21
17
  ): string {
18
+ if (fromPos === undefined) {
19
+ return getCellCode(editor.state);
20
+ }
22
21
  const languageAdapter = editor.state.field(languageAdapterState);
23
22
  const metadata = editor.state.field(languageMetadataField);
24
23
  const editorText = editor.state.doc.toString();
25
- if (fromPos !== undefined) {
26
- return languageAdapter.transformOut(
27
- editorText.slice(fromPos, toPos),
28
- metadata,
29
- )[0];
30
- }
31
- return languageAdapter.transformOut(editorText, metadata)[0];
32
- }
33
-
34
- /**
35
- * Update the editor code from Python code
36
- * Handles when the editor is showing a different language (e.g. markdown)
37
- */
38
- export function updateEditorCodeFromPython(
39
- editor: EditorView,
40
- pythonCode: string,
41
- ): string {
42
- const currentAdapter = editor.state.field(languageAdapterState);
43
- const correctAdapter = languageAdapterFromCode(pythonCode);
44
-
45
- // If the language type changed (e.g., markdown → python), switch adapters
46
- if (correctAdapter.type !== currentAdapter.type) {
47
- switchLanguage(editor, {
48
- language: correctAdapter.type,
49
- keepCodeAsIs: true,
50
- });
51
- }
52
-
53
- const languageAdapter = editor.state.field(languageAdapterState);
54
- const [code] = languageAdapter.transformIn(pythonCode);
55
- // Use replaceEditorContent which preserves cursor position when focused
56
- replaceEditorContent(editor, code);
57
- return code;
24
+ return languageAdapter.transformOut(
25
+ editorText.slice(fromPos, toPos),
26
+ metadata,
27
+ )[0];
58
28
  }
59
29
 
60
30
  /**
@@ -12,10 +12,8 @@ import { Objects } from "@/utils/objects";
12
12
  import { isRecord } from "@/utils/records";
13
13
  import { getPositionAtWordBounds } from "../completion/hints";
14
14
  import { topologicalCodesAtom } from "../copilot/getCodes";
15
- import {
16
- getEditorCodeAsPython,
17
- updateEditorCodeFromPython,
18
- } from "../language/utils";
15
+ import { prepareCellCodeUpdate } from "../language/cell-code";
16
+ import { getEditorCodeAsPython } from "../language/utils";
19
17
  import { createNotebookLens, type NotebookLens } from "./lens";
20
18
  import { normalizeLspDocumentation } from "./normalize-markdown-math";
21
19
  import {
@@ -770,7 +768,10 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
770
768
  // Only update if it has changed
771
769
  const code = getEditorCodeAsPython(ev);
772
770
  if (code !== newCode) {
773
- updateEditorCodeFromPython(ev, newCode);
771
+ const update = prepareCellCodeUpdate(ev.state, newCode);
772
+ if (update) {
773
+ ev.dispatch(update);
774
+ }
774
775
  updatedCount++;
775
776
  }
776
777
  }
@@ -172,7 +172,6 @@ awareness.addListener((updates, origin) => {
172
172
 
173
173
  export function realTimeCollaboration(
174
174
  cellId: CellId,
175
- _updateCellCode: (code: string) => void,
176
175
  initialCode = "",
177
176
  ): { extension: Extension; code: string } {
178
177
  if (isWasm()) {