@marimo-team/islands 0.25.3-dev173 → 0.25.3-dev176
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-wT3f3iJ1.js → ErrorBoundary-D66f-xAE.js} +1 -1
- package/dist/{any-language-editor-B92WMzoC.js → any-language-editor-BVFHDDQ3.js} +2 -2
- package/dist/{chat-ui-CuORr6fL.js → chat-ui-X-cWrL9u.js} +1222 -1222
- package/dist/{code-visibility-ZReaeMaF.js → code-visibility-CVVeTAwi.js} +2 -2
- package/dist/{common-DCevHg1m.js → common-DNlm9Cdj.js} +1074 -1078
- package/dist/{data-grid-overlay-editor-CTC-bJus.js → data-grid-overlay-editor-BArE3_zi.js} +1 -1
- package/dist/{dist-F8Ep36rk.js → dist-B1M0bGO3.js} +1 -1
- package/dist/{dist-D6d86eNJ.js → dist-BA13RJj5.js} +1 -1
- package/dist/{dist-Bvs24R_E.js → dist-BJtmSgcE.js} +48 -48
- package/dist/{dist-CN_BeG2I.js → dist-Ba2VIn1K.js} +53 -53
- package/dist/{dist-PXYI_yuP.js → dist-Bj4bmwUm.js} +12 -12
- package/dist/{dist-DoE4XJBC.js → dist-BmS-ZGjl.js} +8626 -8626
- package/dist/{dist-BorMD-uX.js → dist-Bzogh7CW.js} +87 -87
- package/dist/{dist-CN8zI-XC.js → dist-CThPVKaP.js} +1385 -1382
- package/dist/{dist-DiDibOYr.js → dist-CTi5FTiV.js} +87 -87
- package/dist/{dist-diIx5qJW.js → dist-Ck_C32ye.js} +2 -2
- package/dist/{dist-BLfKmR-l.js → dist-D1fnFp1J.js} +1 -1
- package/dist/{dist-OR1OnN-y.js → dist-D3x9__Fu.js} +46 -46
- package/dist/{dist-BMQf5zIS.js → dist-D7mrh5sO.js} +36 -36
- package/dist/{dist-CSJ0MDRw.js → dist-D9Q3xjiP.js} +1 -1
- package/dist/{dist-BuaRHxOc.js → dist-DP60Ubh8.js} +2 -2
- package/dist/{dist-CSbh1PNP.js → dist-DTv9KkpW.js} +12 -12
- package/dist/{dist-D-wwqlzJ.js → dist-DVVwOfrD.js} +109 -109
- package/dist/{dist-C8fAKSH1.js → dist-DYXM73-k.js} +36 -36
- package/dist/{dist-DBtSl3sf.js → dist-DjeybPOb.js} +1 -1
- package/dist/{dist-CrSP_H1M.js → dist-DtGn9N4h.js} +197 -197
- package/dist/{dist-CLdpGQVr.js → dist-KN80qWCP.js} +2 -2
- package/dist/{dist-Cg7gzw-a.js → dist-O0qFpEJh.js} +56 -56
- package/dist/{dist-CkTTIdBe.js → dist-OUle2RT7.js} +207 -207
- package/dist/{dist-BK4Oj99o.js → dist-T5rh6JvL.js} +1 -1
- package/dist/{error-entries-CmWMEsY_.js → error-entries-mpoD5t79.js} +4939 -4983
- package/dist/{esm-D2gTsXEV.js → esm-Bbw_Njjx.js} +575 -575
- package/dist/{esm-AMFssoNp.js → esm-mDQNEFe_.js} +354 -354
- package/dist/{feedback-button-DwXkmewo.js → feedback-button-CyvqdxXN.js} +21 -21
- package/dist/{glide-data-editor-BlmcP-lw.js → glide-data-editor-BxvYRMC3.js} +3 -3
- package/dist/{globals-BdRitZ4p.js → globals-BJla9q8e.js} +1 -1
- package/dist/{html-to-image-bpiQh7ND.js → html-to-image-BoZthB4P.js} +454 -445
- package/dist/main.js +3587 -3587
- package/dist/{number-overlay-editor-DQG-hFHT.js → number-overlay-editor-BWg7xiRF.js} +1 -1
- package/dist/{process-output-BzloOI7F.js → process-output-DLS4Mqg_.js} +2 -2
- package/dist/{reveal-component-D3r3vSTK.js → reveal-component-feAltY24.js} +3 -3
- package/package.json +1 -1
- package/src/__mocks__/document.tsx +54 -0
- package/src/__mocks__/notebook.ts +2 -1
- package/src/__mocks__/requests.ts +0 -2
- package/src/components/chat/chat-panel.tsx +3 -3
- package/src/components/datasources/__tests__/column-preview.test.tsx +6 -8
- package/src/components/datasources/column-preview.tsx +4 -8
- package/src/components/datasources/datasources.tsx +4 -3
- package/src/components/dependency-graph/minimap-content.tsx +2 -6
- package/src/components/dependency-graph/panels.tsx +2 -1
- package/src/components/editor/__tests__/Output.test.tsx +50 -32
- package/src/components/editor/__tests__/data-attributes.test.tsx +22 -11
- package/src/components/editor/__tests__/output-persistence.test.tsx +6 -1
- package/src/components/editor/actions/__tests__/name-cell-input.test.tsx +69 -0
- package/src/components/editor/actions/name-cell-input.tsx +4 -3
- package/src/components/editor/actions/useCellActionButton.tsx +20 -26
- package/src/components/editor/actions/useNotebookActions.tsx +13 -18
- package/src/components/editor/actions/useSetCodeVisibility.ts +10 -11
- package/src/components/editor/actions/useSetOutputsExpanded.ts +13 -18
- package/src/components/editor/cell/CreateCellButton.tsx +5 -9
- package/src/components/editor/cell/__tests__/StagedAICell.test.tsx +3 -1
- package/src/components/editor/cell/code/cell-editor.tsx +13 -20
- package/src/components/editor/cell/useAddCell.ts +4 -8
- package/src/components/editor/cell/useDeleteCell.tsx +10 -36
- package/src/components/editor/cell/useRunCells.ts +2 -5
- package/src/components/editor/cell/useSplitCell.tsx +2 -1
- package/src/components/editor/chrome/panels/logs-panel.tsx +2 -1
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -11
- package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +3 -1
- package/src/components/editor/columns/sortable-column.tsx +1 -1
- package/src/components/editor/connections/components.tsx +4 -9
- package/src/components/editor/controls/Controls.tsx +1 -1
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +9 -3
- package/src/components/editor/documentation.css +34 -11
- package/src/components/editor/errors/auto-fix.tsx +4 -8
- package/src/components/editor/file-tree/file-explorer.tsx +3 -7
- package/src/components/editor/links/__tests__/cell-link.test.tsx +3 -1
- package/src/components/editor/links/cell-link.tsx +2 -1
- package/src/components/editor/navigation/__tests__/clipboard.test.ts +60 -38
- package/src/components/editor/navigation/__tests__/navigation.test.ts +73 -64
- package/src/components/editor/navigation/clipboard.ts +12 -11
- package/src/components/editor/navigation/multi-cell-action-toolbar.tsx +11 -20
- package/src/components/editor/navigation/navigation.ts +20 -28
- package/src/components/editor/notebook-cell.tsx +7 -10
- package/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx +125 -69
- package/src/components/editor/renderers/cell-array.tsx +10 -17
- package/src/components/markdown/markdown-renderer.tsx +4 -8
- package/src/components/pages/edit-page.tsx +32 -17
- package/src/components/scratchpad/scratchpad.test.tsx +3 -1
- package/src/components/scratchpad/scratchpad.tsx +4 -7
- package/src/components/slides/__tests__/minimap-actions.test.tsx +9 -25
- package/src/components/slides/minimap.tsx +7 -3
- package/src/components/slides/slide-cell-view.tsx +3 -3
- package/src/components/sort/SortableCellsProvider.tsx +2 -1
- package/src/core/ai/__tests__/staged-cells.test.ts +18 -20
- package/src/core/ai/staged-cells.ts +8 -16
- package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +2 -2
- package/src/core/ai/tools/__tests__/run-cells-tool.test.ts +2 -2
- package/src/core/ai/tools/base.ts +2 -2
- package/src/core/ai/tools/edit-notebook-tool.ts +13 -21
- package/src/core/cells/__tests__/actions.test.ts +474 -34
- package/src/core/cells/__tests__/add-missing-import.test.ts +7 -8
- package/src/core/cells/__tests__/cells.test.ts +171 -813
- package/src/core/cells/__tests__/document.test.ts +5 -35
- package/src/core/cells/__tests__/notebook-outline.test.ts +7 -1
- package/src/core/cells/__tests__/outputs.test.tsx +71 -53
- package/src/core/cells/actions.ts +391 -3
- package/src/core/cells/add-missing-import.ts +46 -42
- package/src/core/cells/cells.ts +38 -413
- package/src/core/cells/document.ts +7 -4
- package/src/core/cells/outputs.ts +12 -28
- package/src/core/codemirror/cells/extensions.ts +6 -6
- package/src/core/codemirror/cells/state.ts +1 -1
- package/src/core/codemirror/keymaps/vim.ts +2 -2
- package/src/core/edit-app.tsx +19 -27
- package/src/core/islands/bridge.ts +0 -2
- package/src/core/kernel/handlers.ts +3 -3
- package/src/core/network/__tests__/requests-lazy.test.ts +1 -3
- package/src/core/network/requests-lazy.ts +0 -2
- package/src/core/network/requests-network.ts +0 -16
- package/src/core/network/requests-static.ts +0 -2
- package/src/core/network/requests-toasting.tsx +0 -2
- package/src/core/network/types.ts +0 -7
- package/src/core/notebook/document/__tests__/codemirror.test.ts +5 -0
- package/src/core/notebook/document/__tests__/draft.test.ts +41 -0
- package/src/core/notebook/document/__tests__/state.test.ts +21 -0
- package/src/core/notebook/document/draft.ts +35 -5
- package/src/core/notebook/document/index.ts +1 -0
- package/src/core/notebook/document/state.ts +6 -2
- package/src/core/session/useNotebookSession.tsx +6 -2
- package/src/core/wasm/bridge.ts +0 -16
- package/src/css/globals.css +56 -22
- package/src/stories/runtime-story.tsx +22 -15
|
@@ -107,9 +107,10 @@ describe("applyDocument", () => {
|
|
|
107
107
|
expect(state.cellData[a]).toMatchObject({
|
|
108
108
|
code: "a = 1",
|
|
109
109
|
name: "_",
|
|
110
|
-
edited:
|
|
110
|
+
edited: true,
|
|
111
111
|
lastCodeRun: null,
|
|
112
112
|
});
|
|
113
|
+
expect(state.cellData[b].edited).toBe(false);
|
|
113
114
|
expect(state.cellRuntime[a]).toBeDefined();
|
|
114
115
|
expect(state.cellHandles[a]).toBeDefined();
|
|
115
116
|
expect(state.cellData[SCRATCH_CELL_ID]).toBeDefined();
|
|
@@ -190,7 +191,7 @@ describe("applyDocument", () => {
|
|
|
190
191
|
expect(next.cellData[a]).toMatchObject({
|
|
191
192
|
name: "renamed",
|
|
192
193
|
config: { hide_code: true },
|
|
193
|
-
edited:
|
|
194
|
+
edited: true,
|
|
194
195
|
});
|
|
195
196
|
});
|
|
196
197
|
|
|
@@ -282,45 +283,14 @@ describe("documentEditMiddleware", () => {
|
|
|
282
283
|
store.set(activeDocumentModelAtom, null);
|
|
283
284
|
});
|
|
284
285
|
|
|
285
|
-
it("
|
|
286
|
-
actions.createNewCell({
|
|
287
|
-
cellId: c,
|
|
288
|
-
before: false,
|
|
289
|
-
code: "d = 1",
|
|
290
|
-
newCellId: d,
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
expect(calls.map((call) => call[0])).toEqual(["createCell"]);
|
|
294
|
-
expect(calls[0][1]).toMatchObject({ id: d, code: "d = 1" });
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("creates a cell elsewhere and sets the order", () => {
|
|
298
|
-
actions.createNewCell({ cellId: a, before: true, newCellId: d });
|
|
299
|
-
|
|
300
|
-
expect(calls.map((call) => call[0])).toEqual(["createCell", "setOrder"]);
|
|
301
|
-
expect(calls[1][1]).toEqual([d, a, b, c]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("deletes a cell", () => {
|
|
305
|
-
actions.deleteCell({ cellId: b });
|
|
306
|
-
|
|
307
|
-
expect(calls).toEqual([["deleteCell", b]]);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("writes code, name, and config", () => {
|
|
286
|
+
it("writes code", () => {
|
|
311
287
|
actions.updateCellCode({
|
|
312
288
|
cellId: a,
|
|
313
289
|
code: "a = 2",
|
|
314
290
|
formattingChange: false,
|
|
315
291
|
});
|
|
316
|
-
actions.updateCellName({ cellId: a, name: "first" });
|
|
317
|
-
actions.updateCellConfig({ cellId: a, config: { hide_code: true } });
|
|
318
292
|
|
|
319
|
-
expect(calls).toEqual([
|
|
320
|
-
["setCode", a, "a = 2"],
|
|
321
|
-
["setName", a, "first"],
|
|
322
|
-
["updateConfig", a, { hide_code: true }],
|
|
323
|
-
]);
|
|
293
|
+
expect(calls).toEqual([["setCode", a, "a = 2"]]);
|
|
324
294
|
});
|
|
325
295
|
|
|
326
296
|
it("sets the order when a cell moves", () => {
|
|
@@ -4,6 +4,7 @@ import { createStore } from "jotai";
|
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { MockNotebook } from "@/__mocks__/notebook";
|
|
6
6
|
import { cellId } from "@/__tests__/branded";
|
|
7
|
+
import { CONFIG } from "@/core/notebook/document/__tests__/fixtures";
|
|
7
8
|
import {
|
|
8
9
|
createNotebookActions,
|
|
9
10
|
notebookAtom,
|
|
@@ -84,7 +85,12 @@ describe("notebookOutline", () => {
|
|
|
84
85
|
items: [...secondOutline.items, ...firstOutline.items],
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
actions.
|
|
88
|
+
actions.reconcileDocument({
|
|
89
|
+
order: [first],
|
|
90
|
+
cells: new Map([
|
|
91
|
+
[first, { id: first, code: "x = 1", name: "_", config: CONFIG }],
|
|
92
|
+
]),
|
|
93
|
+
});
|
|
88
94
|
expect(store.get(notebookOutline)).toEqual(firstOutline);
|
|
89
95
|
});
|
|
90
96
|
|
|
@@ -2,116 +2,134 @@
|
|
|
2
2
|
|
|
3
3
|
import { act, renderHook } from "@testing-library/react";
|
|
4
4
|
import { createStore, Provider } from "jotai";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { notebookAtom } from "@/core/cells/cells";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { mockDocumentModel } from "@/__mocks__/document";
|
|
8
7
|
import { CellId } from "@/core/cells/ids";
|
|
8
|
+
import { DocumentProvider } from "@/core/notebook/document";
|
|
9
|
+
import {
|
|
10
|
+
cell,
|
|
11
|
+
CONFIG,
|
|
12
|
+
document as documentOf,
|
|
13
|
+
withCells,
|
|
14
|
+
} from "@/core/notebook/document/__tests__/fixtures";
|
|
9
15
|
import { type AppMode, viewStateAtom } from "@/core/mode";
|
|
10
16
|
import { useExpandedConsoleOutput, useExpandedOutput } from "../outputs";
|
|
11
17
|
|
|
12
|
-
const
|
|
18
|
+
const stops: Array<() => void> = [];
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const stop of stops.splice(0)) {
|
|
22
|
+
stop();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
17
25
|
|
|
18
26
|
function setup(opts: { mode: AppMode; expandOutput?: boolean }) {
|
|
19
27
|
const cellId = CellId.create();
|
|
20
28
|
const store = createStore();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
const { model, stop } = mockDocumentModel(
|
|
30
|
+
store,
|
|
31
|
+
withCells(
|
|
32
|
+
documentOf(),
|
|
33
|
+
{
|
|
34
|
+
c1: null,
|
|
35
|
+
c2: null,
|
|
36
|
+
[cellId]: cell(cellId, "", {
|
|
37
|
+
config: { ...CONFIG, expand_output: opts.expandOutput ?? false },
|
|
38
|
+
}),
|
|
26
39
|
},
|
|
27
|
-
|
|
40
|
+
[cellId],
|
|
41
|
+
),
|
|
28
42
|
);
|
|
43
|
+
stops.push(stop);
|
|
29
44
|
store.set(viewStateAtom, { mode: opts.mode, cellAnchor: null });
|
|
30
45
|
|
|
31
|
-
const {
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
47
|
+
<Provider store={store}>
|
|
48
|
+
<DocumentProvider model={model}>{children}</DocumentProvider>
|
|
49
|
+
</Provider>
|
|
50
|
+
);
|
|
51
|
+
const expanded = () =>
|
|
52
|
+
store.get(model.documentAtom)?.cells.get(cellId)?.config.expand_output;
|
|
34
53
|
|
|
35
|
-
return { cellId, store,
|
|
54
|
+
return { cellId, store, wrapper, expanded };
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
const configOf = (store: ReturnType<typeof createStore>, cellId: CellId) =>
|
|
39
|
-
store.get(notebookAtom).cellData[cellId].config;
|
|
40
|
-
|
|
41
57
|
describe("useExpandedOutput", () => {
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
vi.clearAllMocks();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
58
|
it("reads the initial state from the cell's expand_output config", () => {
|
|
47
|
-
|
|
48
|
-
expect(
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
const collapsed = setup({ mode: "edit" });
|
|
60
|
+
expect(
|
|
61
|
+
renderHook(() => useExpandedOutput(collapsed.cellId), {
|
|
62
|
+
wrapper: collapsed.wrapper,
|
|
63
|
+
}).result.current[0],
|
|
64
|
+
).toBe(false);
|
|
65
|
+
|
|
66
|
+
const expanded = setup({ mode: "edit", expandOutput: true });
|
|
67
|
+
expect(
|
|
68
|
+
renderHook(() => useExpandedOutput(expanded.cellId), {
|
|
69
|
+
wrapper: expanded.wrapper,
|
|
70
|
+
}).result.current[0],
|
|
71
|
+
).toBe(true);
|
|
51
72
|
});
|
|
52
73
|
|
|
53
74
|
it("persists the toggle to the cell's config in edit mode", () => {
|
|
54
|
-
const { cellId,
|
|
75
|
+
const { cellId, wrapper, expanded } = setup({ mode: "edit" });
|
|
76
|
+
const { result } = renderHook(() => useExpandedOutput(cellId), {
|
|
77
|
+
wrapper,
|
|
78
|
+
});
|
|
55
79
|
|
|
56
80
|
act(() => result.current[1](true));
|
|
57
81
|
|
|
58
82
|
expect(result.current[0]).toBe(true);
|
|
59
|
-
expect(
|
|
60
|
-
expect(saveCellConfig).toHaveBeenCalledWith({
|
|
61
|
-
configs: { [cellId]: { expand_output: true } },
|
|
62
|
-
});
|
|
83
|
+
expect(expanded()).toBe(true);
|
|
63
84
|
});
|
|
64
85
|
|
|
65
86
|
it("collapses by clearing the config", () => {
|
|
66
|
-
const { cellId,
|
|
87
|
+
const { cellId, wrapper, expanded } = setup({
|
|
67
88
|
mode: "edit",
|
|
68
89
|
expandOutput: true,
|
|
69
90
|
});
|
|
91
|
+
const { result } = renderHook(() => useExpandedOutput(cellId), {
|
|
92
|
+
wrapper,
|
|
93
|
+
});
|
|
70
94
|
|
|
71
95
|
act(() => result.current[1](false));
|
|
72
96
|
|
|
73
97
|
expect(result.current[0]).toBe(false);
|
|
74
|
-
expect(
|
|
75
|
-
expect(saveCellConfig).toHaveBeenCalledWith({
|
|
76
|
-
configs: { [cellId]: { expand_output: false } },
|
|
77
|
-
});
|
|
98
|
+
expect(expanded()).toBe(false);
|
|
78
99
|
});
|
|
79
100
|
|
|
80
101
|
it("keeps the toggle in memory outside of edit mode", () => {
|
|
81
|
-
const { cellId,
|
|
102
|
+
const { cellId, wrapper, expanded } = setup({ mode: "read" });
|
|
103
|
+
const { result } = renderHook(() => useExpandedOutput(cellId), {
|
|
104
|
+
wrapper,
|
|
105
|
+
});
|
|
82
106
|
|
|
83
107
|
act(() => result.current[1](true));
|
|
84
108
|
|
|
85
109
|
expect(result.current[0]).toBe(true);
|
|
86
110
|
// Cell configs are read-only outside of edit mode.
|
|
87
|
-
expect(
|
|
88
|
-
expect(saveCellConfig).not.toHaveBeenCalled();
|
|
111
|
+
expect(expanded()).toBe(false);
|
|
89
112
|
});
|
|
90
113
|
|
|
91
114
|
it("still honors expand_output outside of edit mode", () => {
|
|
92
|
-
const {
|
|
115
|
+
const { cellId, wrapper } = setup({ mode: "read", expandOutput: true });
|
|
116
|
+
const { result } = renderHook(() => useExpandedOutput(cellId), {
|
|
117
|
+
wrapper,
|
|
118
|
+
});
|
|
93
119
|
expect(result.current[0]).toBe(true);
|
|
94
120
|
});
|
|
95
121
|
|
|
96
122
|
// `expand_output` covers the cell's output area only. Console output is
|
|
97
123
|
// clamped independently and stays an in-memory, per-session toggle.
|
|
98
124
|
it("does not touch the config when console output is expanded", () => {
|
|
99
|
-
const cellId =
|
|
100
|
-
const store = createStore();
|
|
101
|
-
store.set(
|
|
102
|
-
notebookAtom,
|
|
103
|
-
MockNotebook.notebookState({ cellData: { [cellId]: {} } }),
|
|
104
|
-
);
|
|
105
|
-
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
|
|
106
|
-
|
|
125
|
+
const { cellId, wrapper, expanded } = setup({ mode: "edit" });
|
|
107
126
|
const { result } = renderHook(() => useExpandedConsoleOutput(cellId), {
|
|
108
|
-
wrapper
|
|
127
|
+
wrapper,
|
|
109
128
|
});
|
|
110
129
|
|
|
111
130
|
act(() => result.current[1](true));
|
|
112
131
|
|
|
113
132
|
expect(result.current[0]).toBe(true);
|
|
114
|
-
expect(
|
|
115
|
-
expect(saveCellConfig).not.toHaveBeenCalled();
|
|
133
|
+
expect(expanded()).toBe(false);
|
|
116
134
|
});
|
|
117
135
|
});
|
|
@@ -1,14 +1,402 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
/** Notebook operations that edit the document and then adjust the editor. */
|
|
4
|
+
|
|
5
|
+
import { historyField } from "@codemirror/commands";
|
|
6
|
+
import { atom, type Getter, type Setter, useSetAtom } from "jotai";
|
|
7
|
+
import { useMemo } from "react";
|
|
3
8
|
import { scrollAndHighlightCell } from "@/components/editor/links/cell-link";
|
|
9
|
+
import type { DocumentModel, EditableDocument } from "@/core/notebook/document";
|
|
10
|
+
import type { CellColumnId } from "@/utils/id-tree";
|
|
11
|
+
import { Logger } from "@/utils/Logger";
|
|
12
|
+
import type { SerializedEditorState } from "../codemirror/types";
|
|
13
|
+
import type { CellConfig } from "../network/types";
|
|
4
14
|
import { store } from "../state/jotai";
|
|
5
|
-
import {
|
|
15
|
+
import { activeDocumentModelAtom } from "../session/active-notebook";
|
|
16
|
+
import {
|
|
17
|
+
type DeletedCell,
|
|
18
|
+
notebookAtom,
|
|
19
|
+
notebookReducer,
|
|
20
|
+
releaseCellAtoms,
|
|
21
|
+
useReducerActions,
|
|
22
|
+
} from "./cells";
|
|
23
|
+
import { resolveColumns } from "./columns";
|
|
24
|
+
import { CellId, SCRATCH_CELL_ID, SETUP_CELL_ID } from "./ids";
|
|
25
|
+
import { focusAndScrollCellIntoView } from "./scrollCellIntoView";
|
|
26
|
+
|
|
27
|
+
export interface CreateCellOptions {
|
|
28
|
+
/** Place the cell after this one. */
|
|
29
|
+
after?: CellId | null;
|
|
30
|
+
/** Place the cell before this one. */
|
|
31
|
+
before?: CellId | null;
|
|
32
|
+
/** Without an anchor, the column to append to. The first by default. */
|
|
33
|
+
endOf?: CellColumnId;
|
|
34
|
+
/** The id to create with, for callers that refer to the cell before it exists. */
|
|
35
|
+
id?: CellId;
|
|
36
|
+
code?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
/** The cell's config. Its column follows from the placement. */
|
|
39
|
+
config?: Partial<Omit<CellConfig, "column">>;
|
|
40
|
+
/** Scroll to and focus the cell once it mounts. On by default. */
|
|
41
|
+
focus?: boolean;
|
|
42
|
+
/** Create nothing when a cell already has this code. */
|
|
43
|
+
skipIfCodeExists?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CreateCell = (options?: CreateCellOptions) => CellId | null;
|
|
47
|
+
|
|
48
|
+
function modelOf(get: Getter): DocumentModel {
|
|
49
|
+
const model = get(activeDocumentModelAtom);
|
|
50
|
+
if (!model) {
|
|
51
|
+
throw new Error("The notebook has no document to edit");
|
|
52
|
+
}
|
|
53
|
+
return model;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function documentOf(get: Getter): EditableDocument {
|
|
57
|
+
const document = get(modelOf(get).documentAtom);
|
|
58
|
+
if (!document) {
|
|
59
|
+
throw new Error("The notebook has no document to edit");
|
|
60
|
+
}
|
|
61
|
+
return document;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasCellWithCode(document: EditableDocument, code: string): boolean {
|
|
65
|
+
for (const cell of document.cells.values()) {
|
|
66
|
+
if (cell.code === code) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Scroll to and focus the cell once it mounts. */
|
|
74
|
+
function focusWhenMounted(set: Setter, cellId: CellId): void {
|
|
75
|
+
set(notebookAtom, (state) => ({ ...state, scrollKey: cellId }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Create a cell. Returns its id, or null when it was skipped. */
|
|
79
|
+
export const createCellAtom = atom(
|
|
80
|
+
null,
|
|
81
|
+
(get, set, options: CreateCellOptions = {}): CellId | null => {
|
|
82
|
+
const { code = "", focus = true } = options;
|
|
83
|
+
const document = documentOf(get);
|
|
84
|
+
if (options.skipIfCodeExists && hasCellWithCode(document, code)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const { cellIds } = get(notebookAtom);
|
|
88
|
+
|
|
89
|
+
// The document knows cells and order. Columns come from each cell's
|
|
90
|
+
// `column` config, which the cell before it provides unless it starts a
|
|
91
|
+
// column, so a cell placed first in a later column names its column.
|
|
92
|
+
let placement: { after?: CellId; before?: CellId } = {};
|
|
93
|
+
let column: number | null = null;
|
|
94
|
+
if (options.after) {
|
|
95
|
+
placement = { after: options.after };
|
|
96
|
+
} else if (options.before) {
|
|
97
|
+
const tree = cellIds.findWithId(options.before);
|
|
98
|
+
const index = cellIds.indexOf(tree);
|
|
99
|
+
placement = { before: options.before };
|
|
100
|
+
if (index > 0 && tree.indexOfOrThrow(options.before) === 0) {
|
|
101
|
+
column = index;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
const tree =
|
|
105
|
+
(options.endOf && cellIds.get(options.endOf)) ?? cellIds.atOrThrow(0);
|
|
106
|
+
if (tree.length > 0) {
|
|
107
|
+
placement = { after: tree.last() };
|
|
108
|
+
} else if (cellIds.colLength > 1) {
|
|
109
|
+
column = cellIds.indexOf(tree);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const id = options.id ?? CellId.create();
|
|
114
|
+
const config = { ...options.config, column };
|
|
115
|
+
set(modelOf(get).editAtom, (tx) => {
|
|
116
|
+
tx.createCell({ id, code, name: options.name, config }, placement);
|
|
117
|
+
// Only a column's first cell carries its index, as the file is written.
|
|
118
|
+
if (column !== null && options.before) {
|
|
119
|
+
tx.updateConfig(options.before, { column: null });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (focus) {
|
|
123
|
+
focusWhenMounted(set, id);
|
|
124
|
+
// Hidden code stays visible until the cell is blurred for the first time.
|
|
125
|
+
if (config.hide_code) {
|
|
126
|
+
set(notebookAtom, (state) => ({
|
|
127
|
+
...state,
|
|
128
|
+
untouchedNewCells: new Set([...state.untouchedNewCells, id]),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return id;
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Focus the cell before or after `cellId`. Past the edge of the column,
|
|
138
|
+
* create one there, unless `create` is false.
|
|
139
|
+
*/
|
|
140
|
+
export const moveToNextCellAtom = atom(
|
|
141
|
+
null,
|
|
142
|
+
(
|
|
143
|
+
get,
|
|
144
|
+
set,
|
|
145
|
+
{
|
|
146
|
+
cellId,
|
|
147
|
+
before,
|
|
148
|
+
create = true,
|
|
149
|
+
}: { cellId: CellId; before: boolean; create?: boolean },
|
|
150
|
+
): void => {
|
|
151
|
+
if (cellId === SCRATCH_CELL_ID) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const notebook = get(notebookAtom);
|
|
155
|
+
const column = notebook.cellIds.findWithId(cellId);
|
|
156
|
+
const index = column.indexOfOrThrow(cellId);
|
|
157
|
+
const nextIndex = before ? index - 1 : index + 1;
|
|
158
|
+
if (nextIndex < 0 || nextIndex >= column.length) {
|
|
159
|
+
if (create) {
|
|
160
|
+
set(createCellAtom, before ? { before: cellId } : { after: cellId });
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const nextCellId = column.atOrThrow(nextIndex);
|
|
165
|
+
const cell = notebook.cellData[nextCellId];
|
|
166
|
+
focusAndScrollCellIntoView({
|
|
167
|
+
cellId: nextCellId,
|
|
168
|
+
cell: notebook.cellHandles[nextCellId],
|
|
169
|
+
isCodeHidden:
|
|
170
|
+
Boolean(cell.config.hide_code) &&
|
|
171
|
+
!notebook.untouchedNewCells.has(nextCellId),
|
|
172
|
+
codeFocus: before ? "bottom" : "top",
|
|
173
|
+
variableName: undefined,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
/** Focus the setup cell, creating it at the top with `code` if it is missing. */
|
|
179
|
+
export const addSetupCellAtom = atom(
|
|
180
|
+
null,
|
|
181
|
+
(get, set, { code }: { code?: string } = {}): void => {
|
|
182
|
+
const { cellIds } = get(notebookAtom);
|
|
183
|
+
if (cellIds.setupCellExists()) {
|
|
184
|
+
focusWhenMounted(set, SETUP_CELL_ID);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// The setup cell leads the first column, which may be empty.
|
|
188
|
+
const first = cellIds.atOrThrow(0);
|
|
189
|
+
set(createCellAtom, {
|
|
190
|
+
id: SETUP_CELL_ID,
|
|
191
|
+
name: SETUP_CELL_ID,
|
|
192
|
+
code: code ?? "",
|
|
193
|
+
...(first.length > 0
|
|
194
|
+
? { before: first.atOrThrow(0) }
|
|
195
|
+
: { endOf: first.id }),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
);
|
|
6
199
|
|
|
7
200
|
/**
|
|
8
|
-
*
|
|
201
|
+
* Delete cells, keeping them in the history for undo. The notebook keeps at
|
|
202
|
+
* least one cell. Returns false when nothing was deleted.
|
|
9
203
|
*/
|
|
204
|
+
export const deleteCellsAtom = atom(
|
|
205
|
+
null,
|
|
206
|
+
(get, set, { cellIds }: { cellIds: CellId[] }): boolean => {
|
|
207
|
+
const document = documentOf(get);
|
|
208
|
+
const notebook = get(notebookAtom);
|
|
209
|
+
const requested = new Set(cellIds);
|
|
210
|
+
for (const id of cellIds) {
|
|
211
|
+
if (!document.cells.has(id)) {
|
|
212
|
+
// Expected for kernel-only cells or a cell another client removed.
|
|
213
|
+
Logger.warn("Skipping delete for missing cellId", { cellId: id });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const order = document.order as CellId[];
|
|
217
|
+
const deleting = order.flatMap((id) => {
|
|
218
|
+
const cell = document.cells.get(id);
|
|
219
|
+
return cell !== undefined && requested.has(id) ? [cell] : [];
|
|
220
|
+
});
|
|
221
|
+
if (deleting.length === order.length) {
|
|
222
|
+
deleting.pop();
|
|
223
|
+
}
|
|
224
|
+
if (deleting.length === 0) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const gone = new Set(deleting.map((cell) => cell.id as CellId));
|
|
228
|
+
const firstSurvivor = order.find((id) => !gone.has(id));
|
|
229
|
+
|
|
230
|
+
const cells: DeletedCell[] = deleting.map((cell) => {
|
|
231
|
+
const id = cell.id as CellId;
|
|
232
|
+
const index = order.indexOf(id);
|
|
233
|
+
const code = cell.code ?? notebook.cellData[id]?.code ?? "";
|
|
234
|
+
const editorView = notebook.cellHandles[id]?.current?.editorView;
|
|
235
|
+
const serializedEditorState: SerializedEditorState | null =
|
|
236
|
+
editorView?.state.toJSON({ history: historyField }) ?? null;
|
|
237
|
+
if (serializedEditorState) {
|
|
238
|
+
serializedEditorState.doc = code;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
cell: { id, code, name: cell.name, config: cell.config },
|
|
242
|
+
// Anchor to the cell before it, which undo restores first if it is
|
|
243
|
+
// also being deleted. The first cell anchors to the next survivor.
|
|
244
|
+
placement:
|
|
245
|
+
index > 0 ? { after: order[index - 1] } : { before: firstSurvivor },
|
|
246
|
+
serializedEditorState,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Focus the nearest survivor in the column, looking up before down.
|
|
251
|
+
const first = deleting[0].id as CellId;
|
|
252
|
+
const ids = notebook.cellIds.findWithId(first).inOrderIds;
|
|
253
|
+
const at = ids.indexOf(first);
|
|
254
|
+
const scrollKey =
|
|
255
|
+
ids.slice(0, at).findLast((id) => !gone.has(id)) ??
|
|
256
|
+
ids.slice(at + 1).find((id) => !gone.has(id)) ??
|
|
257
|
+
null;
|
|
258
|
+
|
|
259
|
+
const columns = resolveColumns(document);
|
|
260
|
+
set(modelOf(get).editAtom, (tx) => {
|
|
261
|
+
// A cell without a `column` config takes the column of the cell before
|
|
262
|
+
// it, so a survivor that followed a deleted cell names its own.
|
|
263
|
+
let previous: CellId | null = null;
|
|
264
|
+
let followsDeleted = false;
|
|
265
|
+
for (const id of order) {
|
|
266
|
+
if (gone.has(id)) {
|
|
267
|
+
followsDeleted = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const inherited = previous === null ? 0 : columns.get(previous);
|
|
271
|
+
if (
|
|
272
|
+
followsDeleted &&
|
|
273
|
+
document.cells.get(id)?.config.column == null &&
|
|
274
|
+
inherited !== columns.get(id)
|
|
275
|
+
) {
|
|
276
|
+
tx.updateConfig(id, { column: columns.get(id) });
|
|
277
|
+
}
|
|
278
|
+
previous = id;
|
|
279
|
+
followsDeleted = false;
|
|
280
|
+
}
|
|
281
|
+
for (const id of gone) {
|
|
282
|
+
tx.deleteCell(id);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
for (const id of gone) {
|
|
286
|
+
releaseCellAtoms(id);
|
|
287
|
+
}
|
|
288
|
+
set(notebookAtom, (state) => ({
|
|
289
|
+
...state,
|
|
290
|
+
history: [...state.history, { type: "delete", cells }],
|
|
291
|
+
untouchedNewCells: new Set(
|
|
292
|
+
[...state.untouchedNewCells].filter((id) => !gone.has(id)),
|
|
293
|
+
),
|
|
294
|
+
scrollKey,
|
|
295
|
+
}));
|
|
296
|
+
return true;
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/** Undo the last deletion or move. */
|
|
301
|
+
export const undoDeleteCellAtom = atom(null, (get, set): void => {
|
|
302
|
+
const last = get(notebookAtom).history.at(-1);
|
|
303
|
+
if (last === undefined) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (last.type === "move") {
|
|
307
|
+
set(notebookAtom, (state) =>
|
|
308
|
+
notebookReducer(
|
|
309
|
+
{ ...state, history: state.history.slice(0, -1) },
|
|
310
|
+
{ type: "undoMoveCells", payload: last },
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const model = modelOf(get);
|
|
316
|
+
const before = documentOf(get);
|
|
317
|
+
set(model.editAtom, (tx) => {
|
|
318
|
+
// Each cell anchors to the one before it, restored just before it. An
|
|
319
|
+
// anchor another client has removed since falls back to the end.
|
|
320
|
+
const restored = new Set<string>();
|
|
321
|
+
for (const { cell, placement } of last.cells) {
|
|
322
|
+
const anchor = placement.after ?? placement.before;
|
|
323
|
+
const anchored =
|
|
324
|
+
anchor === undefined ||
|
|
325
|
+
before.cells.has(anchor) ||
|
|
326
|
+
restored.has(anchor);
|
|
327
|
+
tx.createCell(cell, anchored ? placement : {});
|
|
328
|
+
restored.add(cell.id);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// Subscribers hear of the edit after the outermost set, so the notebook
|
|
332
|
+
// has not followed the document yet. Catch it up to reach the new cells.
|
|
333
|
+
const document = get(model.documentAtom);
|
|
334
|
+
if (document !== null) {
|
|
335
|
+
set(notebookAtom, (state) =>
|
|
336
|
+
notebookReducer(state, { type: "reconcileDocument", payload: document }),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
set(notebookAtom, (state) => {
|
|
340
|
+
let { cellData } = state;
|
|
341
|
+
for (const { cell, serializedEditorState } of last.cells) {
|
|
342
|
+
const id = cell.id as CellId;
|
|
343
|
+
if (serializedEditorState !== null && cellData[id] !== undefined) {
|
|
344
|
+
cellData = {
|
|
345
|
+
...cellData,
|
|
346
|
+
[id]: { ...cellData[id], serializedEditorState },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
...state,
|
|
352
|
+
cellData,
|
|
353
|
+
history: state.history.slice(0, -1),
|
|
354
|
+
scrollKey: last.cells[0].cell.id as CellId,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every notebook operation, for components. Stable for the store's lifetime.
|
|
361
|
+
*
|
|
362
|
+
* The reducer's actions are included while the reducer still has cases.
|
|
363
|
+
* Outside React, set the atoms on the store directly.
|
|
364
|
+
*/
|
|
365
|
+
export function useCellActions() {
|
|
366
|
+
// The session feeds the kernel's messages and the document into the
|
|
367
|
+
// notebook through `useReducerActions`; components get the operations.
|
|
368
|
+
const {
|
|
369
|
+
handleCellMessage: _handleCellMessage,
|
|
370
|
+
addLogs: _addLogs,
|
|
371
|
+
reconcileDocument: _reconcileDocument,
|
|
372
|
+
setLastRun: _setLastRun,
|
|
373
|
+
resetCellRuntimes: _resetCellRuntimes,
|
|
374
|
+
...operations
|
|
375
|
+
} = useReducerActions();
|
|
376
|
+
const createCell = useSetAtom(createCellAtom);
|
|
377
|
+
const moveToNextCell = useSetAtom(moveToNextCellAtom);
|
|
378
|
+
const addSetupCell = useSetAtom(addSetupCellAtom);
|
|
379
|
+
const deleteCells = useSetAtom(deleteCellsAtom);
|
|
380
|
+
const undoDeleteCell = useSetAtom(undoDeleteCellAtom);
|
|
381
|
+
return useMemo(
|
|
382
|
+
() => ({
|
|
383
|
+
...operations,
|
|
384
|
+
createCell,
|
|
385
|
+
moveToNextCell,
|
|
386
|
+
addSetupCell,
|
|
387
|
+
deleteCells,
|
|
388
|
+
undoDeleteCell,
|
|
389
|
+
}),
|
|
390
|
+
// The reducer's actions are stable for the store's lifetime.
|
|
391
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
+
[createCell, moveToNextCell, addSetupCell, deleteCells, undoDeleteCell],
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export type CellActions = ReturnType<typeof useCellActions>;
|
|
397
|
+
|
|
398
|
+
/** Scroll to the first cell that is currently running. */
|
|
10
399
|
export function notebookScrollToRunning() {
|
|
11
|
-
// find cell that is currently in "running" state
|
|
12
400
|
const { cellIds, cellRuntime } = store.get(notebookAtom);
|
|
13
401
|
const cellId = cellIds.inOrderIds.find(
|
|
14
402
|
(id) => cellRuntime[id]?.status === "running",
|