@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.
- package/dist/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
- package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
- package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
- package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
- package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
- package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
- package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
- package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
- package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
- package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
- package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
- package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
- package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
- package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
- package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
- package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
- package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
- package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
- package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
- package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
- package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
- package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
- package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
- package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
- package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
- package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
- package/dist/main.js +187 -188
- package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
- package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
- package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
- package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
- package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
- package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
- package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
- package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
- package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
- package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
- package/dist/style.css +1 -1
- package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
- package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
- package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
- package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
- package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
- package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
- package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
- package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +0 -1
- package/src/components/editor/cell/StagedAICell.tsx +10 -2
- package/src/components/editor/cell/code/cell-editor.tsx +7 -14
- package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
- package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
- package/src/core/ai/staged-cells.ts +7 -2
- package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
- package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
- package/src/core/cells/__tests__/cells.test.ts +0 -297
- package/src/core/cells/__tests__/document.test.ts +410 -0
- package/src/core/cells/__tests__/flush-document.test.ts +40 -0
- package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
- package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
- package/src/core/cells/cells.ts +27 -158
- package/src/core/cells/columns.ts +38 -0
- package/src/core/cells/document.ts +360 -0
- package/src/core/cells/types.ts +12 -0
- package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
- package/src/core/codemirror/cells/extensions.ts +14 -0
- package/src/core/codemirror/format.ts +6 -5
- package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
- package/src/core/codemirror/language/cell-code.ts +114 -0
- package/src/core/codemirror/language/extension.ts +47 -30
- package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
- package/src/core/codemirror/language/utils.ts +9 -39
- package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
- package/src/core/codemirror/rtc/extension.ts +0 -1
- package/src/core/edit-app.tsx +4 -3
- package/src/core/export/layout.ts +2 -2
- package/src/core/islands/bridge.ts +0 -1
- package/src/core/network/requests-lazy.ts +0 -1
- package/src/core/network/requests-network.ts +0 -9
- package/src/core/network/requests-static.ts +0 -1
- package/src/core/network/requests-toasting.tsx +0 -1
- package/src/core/network/types.ts +0 -5
- package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
- package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
- package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
- package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
- package/src/core/notebook/document/__tests__/model.test.ts +71 -15
- package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
- package/src/core/notebook/document/codemirror.ts +27 -122
- package/src/core/notebook/document/hooks.tsx +2 -2
- package/src/core/notebook/document/index.ts +9 -1
- package/src/core/notebook/document/model.ts +74 -24
- package/src/core/notebook/document/sync.ts +53 -4
- package/src/core/run-app.tsx +44 -41
- package/src/core/saving/save-component.tsx +2 -5
- package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
- package/src/core/session/active-notebook.ts +4 -0
- package/src/core/session/useNotebookSession.tsx +39 -19
- package/src/core/wasm/__tests__/bridge.test.ts +3 -3
- package/src/core/wasm/bridge.ts +5 -12
- package/src/core/wasm/worker/worker.ts +0 -9
- package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
- package/src/utils/createReducer.ts +42 -21
- package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
- package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
- package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
10
|
+
import { prepareCellCodeUpdate } from "../cell-code";
|
|
11
|
+
import { languageMetadataField } from "../metadata";
|
|
10
12
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "../
|
|
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
|
-
|
|
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("
|
|
76
|
-
it("
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
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
|
-
|
|
189
|
+
...languageConfigurationEffects(view.state, nextLanguage),
|
|
168
190
|
setLanguageMetadata.of(metadata),
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
+
const update = prepareCellCodeUpdate(ev.state, newCode);
|
|
772
|
+
if (update) {
|
|
773
|
+
ev.dispatch(update);
|
|
774
|
+
}
|
|
774
775
|
updatedCount++;
|
|
775
776
|
}
|
|
776
777
|
}
|