@marimo-team/islands 0.25.3-dev101 → 0.25.3-dev103
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/{ErrorBoundary-kKLKZNGz.js → ErrorBoundary-DvJIREfH.js} +1 -1
- package/dist/{chat-ui-BIgPxfPP.js → chat-ui-BjFJNtik.js} +3 -3
- package/dist/{code-visibility-CK84K0J7.js → code-visibility-CfeBTR2t.js} +2 -2
- package/dist/{common-DbG2Uufx.js → common-BRSsfrNv.js} +3 -3
- package/dist/{data-grid-overlay-editor-BggIiNWF.js → data-grid-overlay-editor-DEC7lFIy.js} +1 -1
- package/dist/{error-entries-TAO6kEhF.js → error-entries-m7wjcSR-.js} +1 -1
- package/dist/{feedback-button-CRF9v3d1.js → feedback-button-9Gkf9CUC.js} +3 -3
- package/dist/{glide-data-editor-DgZRBzBJ.js → glide-data-editor-D2F4hynZ.js} +3 -3
- package/dist/{globals-Bxo0esfB.js → globals-BBV0iOn6.js} +1 -1
- package/dist/{html-to-image-CWdayGZU.js → html-to-image-DQn1HTpP.js} +1 -1
- package/dist/main.js +10 -10
- package/dist/{number-overlay-editor-CI5EhMRq.js → number-overlay-editor-B3XQt_PV.js} +1 -1
- package/dist/{process-output-DD-oSvZk.js → process-output-DIMk7xHI.js} +2 -2
- package/dist/{reveal-component-Y4-XPwJF.js → reveal-component-DEasliPP.js} +3 -3
- package/package.json +1 -1
- package/src/core/codemirror/cells/__tests__/extensions.test.ts +15 -0
- package/src/core/codemirror/cells/extensions.ts +7 -3
- package/src/core/document/__tests__/codemirror.test.ts +483 -0
- package/src/core/document/__tests__/model.test.ts +6 -6
- package/src/core/document/__tests__/state.test.ts +5 -13
- package/src/core/document/__tests__/submission.test.ts +2 -0
- package/src/core/document/__tests__/user.ts +3 -3
- package/src/core/document/codemirror.ts +311 -0
- package/src/core/document/index.ts +3 -1
- package/src/core/document/model.ts +7 -8
- package/src/core/document/select.ts +41 -0
- package/src/core/document/state.ts +3 -30
- package/src/core/document/sync.ts +29 -25
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { history, undo } from "@codemirror/commands";
|
|
4
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
5
|
+
import { EditorView } from "@codemirror/view";
|
|
6
|
+
import type { CellId } from "@marimo-team/client/protocol";
|
|
7
|
+
import { atom, createStore } from "jotai/vanilla";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { cellId as brandedCellId } from "@/__tests__/branded";
|
|
10
|
+
import { createNotebookActions } from "@/core/cells/cells";
|
|
11
|
+
import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
|
|
12
|
+
import { cellActionsState } from "../../codemirror/cells/state";
|
|
13
|
+
import { cellConfigExtension } from "../../codemirror/config/extension";
|
|
14
|
+
import {
|
|
15
|
+
adaptiveLanguageConfiguration,
|
|
16
|
+
languageAdapterState,
|
|
17
|
+
switchLanguage,
|
|
18
|
+
} from "../../codemirror/language/extension";
|
|
19
|
+
import {
|
|
20
|
+
languageMetadataField,
|
|
21
|
+
setLanguageMetadata,
|
|
22
|
+
} from "../../codemirror/language/metadata";
|
|
23
|
+
import { SCHEMA_CACHE } from "../../codemirror/language/languages/sql/completion-store";
|
|
24
|
+
import type { Document } from "../../host/resources/document";
|
|
25
|
+
import { cellCodeBinding } from "../codemirror";
|
|
26
|
+
import { createDocumentModel } from "../model";
|
|
27
|
+
import { cell, document, withCells } from "./fixtures";
|
|
28
|
+
|
|
29
|
+
const views: EditorView[] = [];
|
|
30
|
+
const lifetimes: AbortController[] = [];
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const view of views.splice(0)) {
|
|
34
|
+
view.destroy();
|
|
35
|
+
}
|
|
36
|
+
lifetimes.splice(0).forEach((controller) => controller.abort());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const completionConfig = {
|
|
40
|
+
activate_on_typing: true,
|
|
41
|
+
signature_hint_on_typing: false,
|
|
42
|
+
copilot: false,
|
|
43
|
+
codeium_api_key: null,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/** The language configuration a marimo cell editor installs. */
|
|
47
|
+
function languages(id: CellId) {
|
|
48
|
+
const options = {
|
|
49
|
+
cellId: brandedCellId(id),
|
|
50
|
+
completionConfig,
|
|
51
|
+
hotkeys: new OverridingHotkeyProvider({}),
|
|
52
|
+
placeholderType: "marimo-import",
|
|
53
|
+
lspConfig: {},
|
|
54
|
+
} as const;
|
|
55
|
+
return [
|
|
56
|
+
cellActionsState.of({
|
|
57
|
+
...createNotebookActions(vi.fn()),
|
|
58
|
+
toggleHideCode: () => false,
|
|
59
|
+
aiCellCompletion: () => false,
|
|
60
|
+
createManyBelow: vi.fn(),
|
|
61
|
+
addOrAppendSetupCell: vi.fn(),
|
|
62
|
+
onRun: vi.fn(),
|
|
63
|
+
deleteCell: vi.fn(),
|
|
64
|
+
afterToggleMarkdown: vi.fn(),
|
|
65
|
+
afterToggleSQL: vi.fn(),
|
|
66
|
+
saveNotebook: vi.fn(),
|
|
67
|
+
}),
|
|
68
|
+
adaptiveLanguageConfiguration(options),
|
|
69
|
+
cellConfigExtension({ ...options, diagnosticsConfig: {} }),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setup({
|
|
74
|
+
initial = document(),
|
|
75
|
+
cellId = "c1",
|
|
76
|
+
doc = "",
|
|
77
|
+
adapters = false,
|
|
78
|
+
}: {
|
|
79
|
+
initial?: Document | null;
|
|
80
|
+
cellId?: CellId;
|
|
81
|
+
doc?: string;
|
|
82
|
+
adapters?: boolean;
|
|
83
|
+
} = {}) {
|
|
84
|
+
const store = createStore();
|
|
85
|
+
const source = atom(initial);
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
lifetimes.push(controller);
|
|
88
|
+
const model = createDocumentModel({
|
|
89
|
+
store,
|
|
90
|
+
notebook: { documentAtom: source, edit: () => new Promise(() => {}) },
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
const editor = (text = doc) => {
|
|
94
|
+
const view = new EditorView({
|
|
95
|
+
state: EditorState.create({
|
|
96
|
+
doc: text,
|
|
97
|
+
extensions: [
|
|
98
|
+
history(),
|
|
99
|
+
adapters ? languages(cellId) : [],
|
|
100
|
+
cellCodeBinding({ store, model, cellId }),
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
views.push(view);
|
|
105
|
+
return view;
|
|
106
|
+
};
|
|
107
|
+
const view = editor();
|
|
108
|
+
return {
|
|
109
|
+
store,
|
|
110
|
+
source,
|
|
111
|
+
model,
|
|
112
|
+
view,
|
|
113
|
+
editor,
|
|
114
|
+
code: () => store.get(model.documentAtom)?.cells.get(cellId)?.code,
|
|
115
|
+
status: () => store.get(model.syncStatusAtom),
|
|
116
|
+
type: (insert: string) =>
|
|
117
|
+
view.dispatch({
|
|
118
|
+
changes: { from: view.state.doc.length, insert },
|
|
119
|
+
userEvent: "input.type",
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Inbound changes apply in a microtask.
|
|
125
|
+
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
126
|
+
|
|
127
|
+
describe("cellCodeBinding", () => {
|
|
128
|
+
it("seeds the editor from the cell outside undo history", async () => {
|
|
129
|
+
const { view, status } = setup();
|
|
130
|
+
await settle();
|
|
131
|
+
expect(view.state.doc.toString()).toBe("x = 1");
|
|
132
|
+
expect(undo(view)).toBe(false);
|
|
133
|
+
expect(status()).toEqual({ status: "idle" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("writes local changes to the draft before the update returns", async () => {
|
|
137
|
+
const { code, status, type } = setup();
|
|
138
|
+
await settle();
|
|
139
|
+
type(" + 1");
|
|
140
|
+
expect(code()).toBe("x = 1 + 1");
|
|
141
|
+
expect(status()).toEqual({ status: "pending" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("shows a change another subscriber makes while a push notifies", async () => {
|
|
145
|
+
const { store, model, view, type } = setup();
|
|
146
|
+
await settle();
|
|
147
|
+
// Another subscriber normalizes code synchronously, inside the push.
|
|
148
|
+
const unsubscribe = store.sub(model.documentAtom, () => {
|
|
149
|
+
const code = store.get(model.documentAtom)?.cells.get("c1")?.code;
|
|
150
|
+
if (code?.includes(" + ")) {
|
|
151
|
+
const normalized = code.replaceAll(" + ", "+");
|
|
152
|
+
store.set(model.editAtom, (tx) => tx.setCode("c1", normalized));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
type(" + 1");
|
|
156
|
+
await settle();
|
|
157
|
+
expect(view.state.doc.toString()).toBe("x = 1+1");
|
|
158
|
+
unsubscribe();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps the selection between separate host edits", async () => {
|
|
162
|
+
const { store, source, view } = setup({
|
|
163
|
+
initial: withCells(document(), { c1: cell("c1", "a = 1\nb = 2\nc = 3") }),
|
|
164
|
+
});
|
|
165
|
+
await settle();
|
|
166
|
+
view.dispatch({ selection: EditorSelection.cursor(8) });
|
|
167
|
+
|
|
168
|
+
store.set(
|
|
169
|
+
source,
|
|
170
|
+
withCells(document(), { c1: cell("c1", "A = 1\nb = 2\nC = 3") }),
|
|
171
|
+
);
|
|
172
|
+
await settle();
|
|
173
|
+
|
|
174
|
+
expect(view.state.doc.toString()).toBe("A = 1\nb = 2\nC = 3");
|
|
175
|
+
expect(view.state.selection.main.head).toBe(8);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("waits for composition to end before applying host changes", async () => {
|
|
179
|
+
const { store, source, view } = setup();
|
|
180
|
+
await settle();
|
|
181
|
+
let composing = true;
|
|
182
|
+
Object.defineProperty(view, "composing", { get: () => composing });
|
|
183
|
+
|
|
184
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 2") }));
|
|
185
|
+
await settle();
|
|
186
|
+
expect(view.state.doc.toString()).toBe("x = 1");
|
|
187
|
+
|
|
188
|
+
// A composition can end without an update, so the binding watches for it.
|
|
189
|
+
composing = false;
|
|
190
|
+
view.contentDOM.dispatchEvent(new Event("compositionend"));
|
|
191
|
+
await settle();
|
|
192
|
+
expect(view.state.doc.toString()).toBe("x = 2");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("applies a waiting host change on the first update after composition", async () => {
|
|
196
|
+
const { store, source, view } = setup();
|
|
197
|
+
await settle();
|
|
198
|
+
let composing = true;
|
|
199
|
+
Object.defineProperty(view, "composing", { get: () => composing });
|
|
200
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 2") }));
|
|
201
|
+
await settle();
|
|
202
|
+
|
|
203
|
+
composing = false;
|
|
204
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
205
|
+
await settle();
|
|
206
|
+
expect(view.state.doc.toString()).toBe("x = 2");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("keeps two editors on one cell in step", async () => {
|
|
210
|
+
const { editor, type } = setup();
|
|
211
|
+
const other = editor();
|
|
212
|
+
await settle();
|
|
213
|
+
|
|
214
|
+
type("!");
|
|
215
|
+
await settle();
|
|
216
|
+
|
|
217
|
+
expect(other.state.doc.toString()).toBe("x = 1!");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("shows host code that returns to what this editor wrote", async () => {
|
|
221
|
+
const { store, source, view, type } = setup();
|
|
222
|
+
await settle();
|
|
223
|
+
type("!");
|
|
224
|
+
// The host confirms the edit, which releases the draft.
|
|
225
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 1!") }));
|
|
226
|
+
await settle();
|
|
227
|
+
|
|
228
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 2") }));
|
|
229
|
+
await settle();
|
|
230
|
+
expect(view.state.doc.toString()).toBe("x = 2");
|
|
231
|
+
|
|
232
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 1!") }));
|
|
233
|
+
await settle();
|
|
234
|
+
expect(view.state.doc.toString()).toBe("x = 1!");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("follows a cell once its code is available", async () => {
|
|
238
|
+
const absent = setup({ cellId: "c3" });
|
|
239
|
+
const withheld = setup({
|
|
240
|
+
initial: withCells(document(), { c1: cell("c1", "", { code: null }) }),
|
|
241
|
+
});
|
|
242
|
+
await settle();
|
|
243
|
+
|
|
244
|
+
absent.store.set(
|
|
245
|
+
absent.source,
|
|
246
|
+
withCells(document(), { c3: cell("c3", "new") }),
|
|
247
|
+
);
|
|
248
|
+
withheld.store.set(
|
|
249
|
+
withheld.source,
|
|
250
|
+
withCells(document(), { c1: cell("c1", "revealed") }),
|
|
251
|
+
);
|
|
252
|
+
await settle();
|
|
253
|
+
|
|
254
|
+
expect(absent.view.state.doc.toString()).toBe("new");
|
|
255
|
+
expect(withheld.view.state.doc.toString()).toBe("revealed");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("shows the host's code after typing while the document was unavailable", async () => {
|
|
259
|
+
const { store, source, view, type, code } = setup();
|
|
260
|
+
await settle();
|
|
261
|
+
|
|
262
|
+
// All in one task, so no pull runs while the document is away.
|
|
263
|
+
store.set(source, null);
|
|
264
|
+
type(" + 1");
|
|
265
|
+
store.set(source, document());
|
|
266
|
+
await settle();
|
|
267
|
+
|
|
268
|
+
expect(view.state.doc.toString()).toBe(code());
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("leaves the editor alone for an absent cell or withheld source", async () => {
|
|
272
|
+
const absent = setup({ cellId: "c9", doc: "local" });
|
|
273
|
+
const withheld = setup({
|
|
274
|
+
initial: withCells(document(), { c1: cell("c1", "", { code: null }) }),
|
|
275
|
+
doc: "local",
|
|
276
|
+
});
|
|
277
|
+
await settle();
|
|
278
|
+
|
|
279
|
+
for (const { view, type, status } of [absent, withheld]) {
|
|
280
|
+
type("!");
|
|
281
|
+
expect(view.state.doc.toString()).toBe("local!");
|
|
282
|
+
expect(status()).toEqual({ status: "idle" });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("stops following the cell when the view is destroyed", async () => {
|
|
287
|
+
const { store, source, view } = setup();
|
|
288
|
+
await settle();
|
|
289
|
+
|
|
290
|
+
// The change arrives before the view goes, and would apply just after.
|
|
291
|
+
store.set(source, withCells(document(), { c1: cell("c1", "x = 2") }));
|
|
292
|
+
view.destroy();
|
|
293
|
+
await settle();
|
|
294
|
+
|
|
295
|
+
expect(view.state.doc.toString()).toBe("x = 1");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("with language adapters", () => {
|
|
299
|
+
const language = (view: EditorView) =>
|
|
300
|
+
view.state.field(languageAdapterState).type;
|
|
301
|
+
/** What the editor's adapter reads back from cell code. */
|
|
302
|
+
const readBack = (view: EditorView, code: string | null | undefined) => {
|
|
303
|
+
const [text, , metadata] = view.state
|
|
304
|
+
.field(languageAdapterState)
|
|
305
|
+
.transformIn(code ?? "");
|
|
306
|
+
return { text, metadata };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
it("edits markdown as markdown and writes Python with its metadata", async () => {
|
|
310
|
+
const { view, code, type } = setup({
|
|
311
|
+
adapters: true,
|
|
312
|
+
initial: withCells(document(), {
|
|
313
|
+
c1: cell("c1", 'mo.md(f"""hi {x}""")'),
|
|
314
|
+
}),
|
|
315
|
+
});
|
|
316
|
+
await settle();
|
|
317
|
+
expect(language(view)).toBe("markdown");
|
|
318
|
+
expect(view.state.doc.toString()).toBe("hi {x}");
|
|
319
|
+
|
|
320
|
+
type("!");
|
|
321
|
+
expect(readBack(view, code())).toMatchObject({
|
|
322
|
+
text: "hi {x}!",
|
|
323
|
+
metadata: { quotePrefix: "f" },
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("switches language for host code without rewriting it", async () => {
|
|
328
|
+
const { store, source, view, code, status } = setup({ adapters: true });
|
|
329
|
+
await settle();
|
|
330
|
+
expect(language(view)).toBe("python");
|
|
331
|
+
|
|
332
|
+
store.set(
|
|
333
|
+
source,
|
|
334
|
+
withCells(document(), { c1: cell("c1", 'mo.md("hi")') }),
|
|
335
|
+
);
|
|
336
|
+
await settle();
|
|
337
|
+
|
|
338
|
+
expect(language(view)).toBe("markdown");
|
|
339
|
+
expect(view.state.doc.toString()).toBe("hi");
|
|
340
|
+
expect(code()).toBe('mo.md("hi")');
|
|
341
|
+
expect(status()).toEqual({ status: "idle" });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("writes changes to language metadata alone", async () => {
|
|
345
|
+
const { view, code } = setup({
|
|
346
|
+
adapters: true,
|
|
347
|
+
initial: withCells(document(), {
|
|
348
|
+
c1: cell("c1", 'mo.md("""\n# Title\n""")'),
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
await settle();
|
|
352
|
+
|
|
353
|
+
view.dispatch({
|
|
354
|
+
effects: setLanguageMetadata.of({
|
|
355
|
+
...view.state.field(languageMetadataField),
|
|
356
|
+
quotePrefix: "r",
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
expect(readBack(view, code())).toMatchObject({
|
|
360
|
+
text: "# Title",
|
|
361
|
+
metadata: { quotePrefix: "r" },
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("applies a host change to language metadata alone", async () => {
|
|
366
|
+
const { store, source, view } = setup({
|
|
367
|
+
adapters: true,
|
|
368
|
+
initial: withCells(document(), {
|
|
369
|
+
c1: cell("c1", 'mo.md(r"""hi""")'),
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
await settle();
|
|
373
|
+
|
|
374
|
+
store.set(
|
|
375
|
+
source,
|
|
376
|
+
withCells(document(), { c1: cell("c1", 'mo.md(f"""hi""")') }),
|
|
377
|
+
);
|
|
378
|
+
await settle();
|
|
379
|
+
|
|
380
|
+
expect(view.state.doc.toString()).toBe("hi");
|
|
381
|
+
expect(view.state.field(languageMetadataField)).toMatchObject({
|
|
382
|
+
quotePrefix: "f",
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("does not rewrite code when the editor switches language as it opens", async () => {
|
|
387
|
+
const original = 'mo.md("hello")';
|
|
388
|
+
const { view, code, status } = setup({
|
|
389
|
+
adapters: true,
|
|
390
|
+
doc: original,
|
|
391
|
+
initial: withCells(document(), { c1: cell("c1", original) }),
|
|
392
|
+
});
|
|
393
|
+
// The cell editor switches to the language its code implies on mount.
|
|
394
|
+
switchLanguage(view, { language: "markdown" });
|
|
395
|
+
await settle();
|
|
396
|
+
|
|
397
|
+
expect(code()).toBe(original);
|
|
398
|
+
expect(status()).toEqual({ status: "idle" });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("keeps typed text that the adapter would normalize", async () => {
|
|
402
|
+
const { view, type } = setup({
|
|
403
|
+
adapters: true,
|
|
404
|
+
initial: withCells(document(), {
|
|
405
|
+
c1: cell("c1", 'mo.md("""\nhi\n""")'),
|
|
406
|
+
}),
|
|
407
|
+
});
|
|
408
|
+
await settle();
|
|
409
|
+
|
|
410
|
+
type("\n");
|
|
411
|
+
await settle();
|
|
412
|
+
type(" ");
|
|
413
|
+
await settle();
|
|
414
|
+
|
|
415
|
+
expect(view.state.doc.toString()).toBe("hi\n ");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("keeps the language a person chose when the cell changes otherwise", async () => {
|
|
419
|
+
const { store, model, view } = setup({
|
|
420
|
+
adapters: true,
|
|
421
|
+
initial: withCells(document(), {
|
|
422
|
+
c1: cell("c1", 'mo.md("""\nhi\n""")'),
|
|
423
|
+
}),
|
|
424
|
+
});
|
|
425
|
+
await settle();
|
|
426
|
+
switchLanguage(view, { language: "python" });
|
|
427
|
+
await settle();
|
|
428
|
+
|
|
429
|
+
store.set(model.editAtom, (tx) => tx.setName("c1", "renamed"));
|
|
430
|
+
await settle();
|
|
431
|
+
|
|
432
|
+
expect(language(view)).toBe("python");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("edits SQL as SQL and keeps its output name", async () => {
|
|
436
|
+
const { store, source, view, code, type } = setup({ adapters: true });
|
|
437
|
+
await settle();
|
|
438
|
+
|
|
439
|
+
store.set(
|
|
440
|
+
source,
|
|
441
|
+
withCells(document(), {
|
|
442
|
+
c1: cell("c1", 'df = mo.sql(f"""SELECT 1""")'),
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
await settle();
|
|
446
|
+
expect(language(view)).toBe("sql");
|
|
447
|
+
expect(view.state.doc.toString()).toBe("SELECT 1");
|
|
448
|
+
|
|
449
|
+
type(" LIMIT 1");
|
|
450
|
+
expect(readBack(view, code())).toMatchObject({
|
|
451
|
+
text: "SELECT 1 LIMIT 1",
|
|
452
|
+
metadata: { dataframeName: "df" },
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("sets the SQL dialect from the host's engine", async () => {
|
|
457
|
+
const { store, source } = setup({ adapters: true });
|
|
458
|
+
await settle();
|
|
459
|
+
const dialect = vi.spyOn(SCHEMA_CACHE, "getDialect");
|
|
460
|
+
try {
|
|
461
|
+
store.set(
|
|
462
|
+
source,
|
|
463
|
+
withCells(document(), {
|
|
464
|
+
c1: cell("c1", '_df = mo.sql(f"""SELECT 1""", engine=warehouse)'),
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
await settle();
|
|
468
|
+
expect(dialect).toHaveBeenLastCalledWith("warehouse");
|
|
469
|
+
|
|
470
|
+
store.set(
|
|
471
|
+
source,
|
|
472
|
+
withCells(document(), {
|
|
473
|
+
c1: cell("c1", '_df = mo.sql(f"""SELECT 1""", engine=lake)'),
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
await settle();
|
|
477
|
+
expect(dialect).toHaveBeenLastCalledWith("lake");
|
|
478
|
+
} finally {
|
|
479
|
+
dialect.mockRestore();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
});
|
|
@@ -67,8 +67,8 @@ function setup() {
|
|
|
67
67
|
unmounted,
|
|
68
68
|
edit: (code: string) =>
|
|
69
69
|
store.set(model.editAtom, (tx) => tx.setCode("c1", code)),
|
|
70
|
-
|
|
71
|
-
value: () => store.get(model.
|
|
70
|
+
status: () => store.get(model.syncStatusAtom),
|
|
71
|
+
value: () => store.get(model.documentAtom)?.cells.get("c1"),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -102,7 +102,7 @@ describe("document synchronization", () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it("fails without repeating a request when the response names another notebook", async () => {
|
|
105
|
-
const { sent, replies, edit, model,
|
|
105
|
+
const { sent, replies, edit, model, status } = setup();
|
|
106
106
|
edit("local");
|
|
107
107
|
const rejected = expectFailure(model.flush(), "another notebook");
|
|
108
108
|
await vi.advanceTimersByTimeAsync(0);
|
|
@@ -110,7 +110,7 @@ describe("document synchronization", () => {
|
|
|
110
110
|
await rejected;
|
|
111
111
|
await vi.advanceTimersByTimeAsync(5000);
|
|
112
112
|
expect(sent).toHaveBeenCalledOnce();
|
|
113
|
-
expect(
|
|
113
|
+
expect(status()).toMatchObject({ status: "error" });
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it("ignores a late response and source update after its lifetime ends", async () => {
|
|
@@ -145,7 +145,7 @@ describe("document synchronization", () => {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
it("aborts an in-flight edit when notebook identity changes", async () => {
|
|
148
|
-
const { store, source, sent, edit, model,
|
|
148
|
+
const { store, source, sent, edit, model, status } = setup();
|
|
149
149
|
edit("local");
|
|
150
150
|
const flushed = model.flush();
|
|
151
151
|
const rejected = expectFailure(flushed, "notebook changed");
|
|
@@ -153,6 +153,6 @@ describe("document synchronization", () => {
|
|
|
153
153
|
store.set(source, document("n2"));
|
|
154
154
|
await rejected;
|
|
155
155
|
expect(sent.mock.calls[0][0].signal.aborted).toBe(true);
|
|
156
|
-
expect(
|
|
156
|
+
expect(status()).toMatchObject({ status: "error", retryable: false });
|
|
157
157
|
});
|
|
158
158
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DocumentEdit,
|
|
9
9
|
type DocumentEditor,
|
|
10
10
|
} from "../state";
|
|
11
|
+
import { selectCell, selectOrder } from "../select";
|
|
11
12
|
import { cell, document, withCells } from "./fixtures";
|
|
12
13
|
|
|
13
14
|
const lifetimes: AbortController[] = [];
|
|
@@ -29,6 +30,8 @@ function setup(initial: Document | null = document()) {
|
|
|
29
30
|
store,
|
|
30
31
|
source,
|
|
31
32
|
...model,
|
|
33
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
34
|
+
orderAtom: selectOrder(model.documentAtom),
|
|
32
35
|
abort: () => controller.abort(),
|
|
33
36
|
edit: (callback: DocumentEdit) => store.set(model.editAtom, callback),
|
|
34
37
|
};
|
|
@@ -252,10 +255,9 @@ describe("document model", () => {
|
|
|
252
255
|
unsubscribeOrder();
|
|
253
256
|
});
|
|
254
257
|
|
|
255
|
-
it("isolates cell
|
|
258
|
+
it("isolates cell and order subscriptions across local and host updates", () => {
|
|
256
259
|
const initial = document();
|
|
257
|
-
const { store, source, cellAtom, orderAtom,
|
|
258
|
-
setup(initial);
|
|
260
|
+
const { store, source, cellAtom, orderAtom, edit } = setup(initial);
|
|
259
261
|
edit((tx) => {
|
|
260
262
|
tx.setCode("c1", "local");
|
|
261
263
|
tx.createCell({ id: "c3", code: "new" });
|
|
@@ -263,16 +265,13 @@ describe("document model", () => {
|
|
|
263
265
|
const c1 = cellAtom("c1");
|
|
264
266
|
const first = store.get(c1);
|
|
265
267
|
const order = store.get(orderAtom);
|
|
266
|
-
const config = store.get(appConfigAtom);
|
|
267
268
|
const onFirst = vi.fn();
|
|
268
269
|
const onSecond = vi.fn();
|
|
269
270
|
const onOrder = vi.fn();
|
|
270
|
-
const onConfig = vi.fn();
|
|
271
271
|
const unsubscribe = [
|
|
272
272
|
store.sub(c1, onFirst),
|
|
273
273
|
store.sub(cellAtom("c2"), onSecond),
|
|
274
274
|
store.sub(orderAtom, onOrder),
|
|
275
|
-
store.sub(appConfigAtom, onConfig),
|
|
276
275
|
];
|
|
277
276
|
|
|
278
277
|
edit((tx) => tx.setCode("c2", "other local"));
|
|
@@ -289,11 +288,9 @@ describe("document model", () => {
|
|
|
289
288
|
});
|
|
290
289
|
expect(store.get(c1)).toBe(first);
|
|
291
290
|
expect(store.get(orderAtom)).toBe(order);
|
|
292
|
-
expect(store.get(appConfigAtom)).toBe(config);
|
|
293
291
|
expect(onFirst).not.toHaveBeenCalled();
|
|
294
292
|
expect(onSecond).toHaveBeenCalledOnce();
|
|
295
293
|
expect(onOrder).not.toHaveBeenCalled();
|
|
296
|
-
expect(onConfig).not.toHaveBeenCalled();
|
|
297
294
|
|
|
298
295
|
store.set(
|
|
299
296
|
source,
|
|
@@ -311,11 +308,6 @@ describe("document model", () => {
|
|
|
311
308
|
edit((tx) => tx.setOrder(["c3", "c2", "c1"]));
|
|
312
309
|
expect(onOrder).toHaveBeenCalledOnce();
|
|
313
310
|
expect(onFirst).toHaveBeenCalledOnce();
|
|
314
|
-
store.set(source, {
|
|
315
|
-
...remote,
|
|
316
|
-
revision: { ...remote.revision, app_config: { width: "full" } },
|
|
317
|
-
});
|
|
318
|
-
expect(onConfig).toHaveBeenCalledOnce();
|
|
319
311
|
unsubscribe.forEach((fn) => fn());
|
|
320
312
|
});
|
|
321
313
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { atom, createStore } from "jotai/vanilla";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { Document } from "../../host/resources/document";
|
|
6
|
+
import { selectCell } from "../select";
|
|
6
7
|
import { createDocumentState, type DocumentSubmission } from "../state";
|
|
7
8
|
import { cell, document, withCells } from "./fixtures";
|
|
8
9
|
|
|
@@ -25,6 +26,7 @@ function setup() {
|
|
|
25
26
|
store,
|
|
26
27
|
source,
|
|
27
28
|
...model,
|
|
29
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
28
30
|
submit() {
|
|
29
31
|
const submission = store.set(model.submitAtom);
|
|
30
32
|
if (submission === null) {
|
|
@@ -43,7 +43,7 @@ export function createUser(
|
|
|
43
43
|
store.set(model.editAtom, (tx) => tx.deleteCell(id));
|
|
44
44
|
},
|
|
45
45
|
cell(id: Protocol.CellId) {
|
|
46
|
-
const cell = store.get(model.
|
|
46
|
+
const cell = store.get(model.documentAtom)?.cells.get(id);
|
|
47
47
|
return cell === undefined
|
|
48
48
|
? undefined
|
|
49
49
|
: {
|
|
@@ -53,8 +53,8 @@ export function createUser(
|
|
|
53
53
|
config: cell.config,
|
|
54
54
|
};
|
|
55
55
|
},
|
|
56
|
-
order: () => store.get(model.
|
|
57
|
-
status: () => store.get(model.
|
|
56
|
+
order: () => store.get(model.documentAtom)?.order ?? null,
|
|
57
|
+
status: () => store.get(model.syncStatusAtom),
|
|
58
58
|
sync: () => observe(model.flush()),
|
|
59
59
|
retry: () => observe(model.retry()),
|
|
60
60
|
};
|