@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.
Files changed (134) hide show
  1. package/dist/{ErrorBoundary-wT3f3iJ1.js → ErrorBoundary-D66f-xAE.js} +1 -1
  2. package/dist/{any-language-editor-B92WMzoC.js → any-language-editor-BVFHDDQ3.js} +2 -2
  3. package/dist/{chat-ui-CuORr6fL.js → chat-ui-X-cWrL9u.js} +1222 -1222
  4. package/dist/{code-visibility-ZReaeMaF.js → code-visibility-CVVeTAwi.js} +2 -2
  5. package/dist/{common-DCevHg1m.js → common-DNlm9Cdj.js} +1074 -1078
  6. package/dist/{data-grid-overlay-editor-CTC-bJus.js → data-grid-overlay-editor-BArE3_zi.js} +1 -1
  7. package/dist/{dist-F8Ep36rk.js → dist-B1M0bGO3.js} +1 -1
  8. package/dist/{dist-D6d86eNJ.js → dist-BA13RJj5.js} +1 -1
  9. package/dist/{dist-Bvs24R_E.js → dist-BJtmSgcE.js} +48 -48
  10. package/dist/{dist-CN_BeG2I.js → dist-Ba2VIn1K.js} +53 -53
  11. package/dist/{dist-PXYI_yuP.js → dist-Bj4bmwUm.js} +12 -12
  12. package/dist/{dist-DoE4XJBC.js → dist-BmS-ZGjl.js} +8626 -8626
  13. package/dist/{dist-BorMD-uX.js → dist-Bzogh7CW.js} +87 -87
  14. package/dist/{dist-CN8zI-XC.js → dist-CThPVKaP.js} +1385 -1382
  15. package/dist/{dist-DiDibOYr.js → dist-CTi5FTiV.js} +87 -87
  16. package/dist/{dist-diIx5qJW.js → dist-Ck_C32ye.js} +2 -2
  17. package/dist/{dist-BLfKmR-l.js → dist-D1fnFp1J.js} +1 -1
  18. package/dist/{dist-OR1OnN-y.js → dist-D3x9__Fu.js} +46 -46
  19. package/dist/{dist-BMQf5zIS.js → dist-D7mrh5sO.js} +36 -36
  20. package/dist/{dist-CSJ0MDRw.js → dist-D9Q3xjiP.js} +1 -1
  21. package/dist/{dist-BuaRHxOc.js → dist-DP60Ubh8.js} +2 -2
  22. package/dist/{dist-CSbh1PNP.js → dist-DTv9KkpW.js} +12 -12
  23. package/dist/{dist-D-wwqlzJ.js → dist-DVVwOfrD.js} +109 -109
  24. package/dist/{dist-C8fAKSH1.js → dist-DYXM73-k.js} +36 -36
  25. package/dist/{dist-DBtSl3sf.js → dist-DjeybPOb.js} +1 -1
  26. package/dist/{dist-CrSP_H1M.js → dist-DtGn9N4h.js} +197 -197
  27. package/dist/{dist-CLdpGQVr.js → dist-KN80qWCP.js} +2 -2
  28. package/dist/{dist-Cg7gzw-a.js → dist-O0qFpEJh.js} +56 -56
  29. package/dist/{dist-CkTTIdBe.js → dist-OUle2RT7.js} +207 -207
  30. package/dist/{dist-BK4Oj99o.js → dist-T5rh6JvL.js} +1 -1
  31. package/dist/{error-entries-CmWMEsY_.js → error-entries-mpoD5t79.js} +4939 -4983
  32. package/dist/{esm-D2gTsXEV.js → esm-Bbw_Njjx.js} +575 -575
  33. package/dist/{esm-AMFssoNp.js → esm-mDQNEFe_.js} +354 -354
  34. package/dist/{feedback-button-DwXkmewo.js → feedback-button-CyvqdxXN.js} +21 -21
  35. package/dist/{glide-data-editor-BlmcP-lw.js → glide-data-editor-BxvYRMC3.js} +3 -3
  36. package/dist/{globals-BdRitZ4p.js → globals-BJla9q8e.js} +1 -1
  37. package/dist/{html-to-image-bpiQh7ND.js → html-to-image-BoZthB4P.js} +454 -445
  38. package/dist/main.js +3587 -3587
  39. package/dist/{number-overlay-editor-DQG-hFHT.js → number-overlay-editor-BWg7xiRF.js} +1 -1
  40. package/dist/{process-output-BzloOI7F.js → process-output-DLS4Mqg_.js} +2 -2
  41. package/dist/{reveal-component-D3r3vSTK.js → reveal-component-feAltY24.js} +3 -3
  42. package/package.json +1 -1
  43. package/src/__mocks__/document.tsx +54 -0
  44. package/src/__mocks__/notebook.ts +2 -1
  45. package/src/__mocks__/requests.ts +0 -2
  46. package/src/components/chat/chat-panel.tsx +3 -3
  47. package/src/components/datasources/__tests__/column-preview.test.tsx +6 -8
  48. package/src/components/datasources/column-preview.tsx +4 -8
  49. package/src/components/datasources/datasources.tsx +4 -3
  50. package/src/components/dependency-graph/minimap-content.tsx +2 -6
  51. package/src/components/dependency-graph/panels.tsx +2 -1
  52. package/src/components/editor/__tests__/Output.test.tsx +50 -32
  53. package/src/components/editor/__tests__/data-attributes.test.tsx +22 -11
  54. package/src/components/editor/__tests__/output-persistence.test.tsx +6 -1
  55. package/src/components/editor/actions/__tests__/name-cell-input.test.tsx +69 -0
  56. package/src/components/editor/actions/name-cell-input.tsx +4 -3
  57. package/src/components/editor/actions/useCellActionButton.tsx +20 -26
  58. package/src/components/editor/actions/useNotebookActions.tsx +13 -18
  59. package/src/components/editor/actions/useSetCodeVisibility.ts +10 -11
  60. package/src/components/editor/actions/useSetOutputsExpanded.ts +13 -18
  61. package/src/components/editor/cell/CreateCellButton.tsx +5 -9
  62. package/src/components/editor/cell/__tests__/StagedAICell.test.tsx +3 -1
  63. package/src/components/editor/cell/code/cell-editor.tsx +13 -20
  64. package/src/components/editor/cell/useAddCell.ts +4 -8
  65. package/src/components/editor/cell/useDeleteCell.tsx +10 -36
  66. package/src/components/editor/cell/useRunCells.ts +2 -5
  67. package/src/components/editor/cell/useSplitCell.tsx +2 -1
  68. package/src/components/editor/chrome/panels/logs-panel.tsx +2 -1
  69. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -11
  70. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +3 -1
  71. package/src/components/editor/columns/sortable-column.tsx +1 -1
  72. package/src/components/editor/connections/components.tsx +4 -9
  73. package/src/components/editor/controls/Controls.tsx +1 -1
  74. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +9 -3
  75. package/src/components/editor/documentation.css +34 -11
  76. package/src/components/editor/errors/auto-fix.tsx +4 -8
  77. package/src/components/editor/file-tree/file-explorer.tsx +3 -7
  78. package/src/components/editor/links/__tests__/cell-link.test.tsx +3 -1
  79. package/src/components/editor/links/cell-link.tsx +2 -1
  80. package/src/components/editor/navigation/__tests__/clipboard.test.ts +60 -38
  81. package/src/components/editor/navigation/__tests__/navigation.test.ts +73 -64
  82. package/src/components/editor/navigation/clipboard.ts +12 -11
  83. package/src/components/editor/navigation/multi-cell-action-toolbar.tsx +11 -20
  84. package/src/components/editor/navigation/navigation.ts +20 -28
  85. package/src/components/editor/notebook-cell.tsx +7 -10
  86. package/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx +125 -69
  87. package/src/components/editor/renderers/cell-array.tsx +10 -17
  88. package/src/components/markdown/markdown-renderer.tsx +4 -8
  89. package/src/components/pages/edit-page.tsx +32 -17
  90. package/src/components/scratchpad/scratchpad.test.tsx +3 -1
  91. package/src/components/scratchpad/scratchpad.tsx +4 -7
  92. package/src/components/slides/__tests__/minimap-actions.test.tsx +9 -25
  93. package/src/components/slides/minimap.tsx +7 -3
  94. package/src/components/slides/slide-cell-view.tsx +3 -3
  95. package/src/components/sort/SortableCellsProvider.tsx +2 -1
  96. package/src/core/ai/__tests__/staged-cells.test.ts +18 -20
  97. package/src/core/ai/staged-cells.ts +8 -16
  98. package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +2 -2
  99. package/src/core/ai/tools/__tests__/run-cells-tool.test.ts +2 -2
  100. package/src/core/ai/tools/base.ts +2 -2
  101. package/src/core/ai/tools/edit-notebook-tool.ts +13 -21
  102. package/src/core/cells/__tests__/actions.test.ts +474 -34
  103. package/src/core/cells/__tests__/add-missing-import.test.ts +7 -8
  104. package/src/core/cells/__tests__/cells.test.ts +171 -813
  105. package/src/core/cells/__tests__/document.test.ts +5 -35
  106. package/src/core/cells/__tests__/notebook-outline.test.ts +7 -1
  107. package/src/core/cells/__tests__/outputs.test.tsx +71 -53
  108. package/src/core/cells/actions.ts +391 -3
  109. package/src/core/cells/add-missing-import.ts +46 -42
  110. package/src/core/cells/cells.ts +38 -413
  111. package/src/core/cells/document.ts +7 -4
  112. package/src/core/cells/outputs.ts +12 -28
  113. package/src/core/codemirror/cells/extensions.ts +6 -6
  114. package/src/core/codemirror/cells/state.ts +1 -1
  115. package/src/core/codemirror/keymaps/vim.ts +2 -2
  116. package/src/core/edit-app.tsx +19 -27
  117. package/src/core/islands/bridge.ts +0 -2
  118. package/src/core/kernel/handlers.ts +3 -3
  119. package/src/core/network/__tests__/requests-lazy.test.ts +1 -3
  120. package/src/core/network/requests-lazy.ts +0 -2
  121. package/src/core/network/requests-network.ts +0 -16
  122. package/src/core/network/requests-static.ts +0 -2
  123. package/src/core/network/requests-toasting.tsx +0 -2
  124. package/src/core/network/types.ts +0 -7
  125. package/src/core/notebook/document/__tests__/codemirror.test.ts +5 -0
  126. package/src/core/notebook/document/__tests__/draft.test.ts +41 -0
  127. package/src/core/notebook/document/__tests__/state.test.ts +21 -0
  128. package/src/core/notebook/document/draft.ts +35 -5
  129. package/src/core/notebook/document/index.ts +1 -0
  130. package/src/core/notebook/document/state.ts +6 -2
  131. package/src/core/session/useNotebookSession.tsx +6 -2
  132. package/src/core/wasm/bridge.ts +0 -16
  133. package/src/css/globals.css +56 -22
  134. 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: false,
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: false,
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("creates a cell at the end without setting the order", () => {
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.deleteCell({ cellId: second });
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 { beforeEach, describe, expect, it, vi } from "vitest";
6
- import { MockNotebook } from "@/__mocks__/notebook";
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 saveCellConfig = vi.fn().mockResolvedValue(null);
18
+ const stops: Array<() => void> = [];
13
19
 
14
- vi.mock("@/core/network/requests", () => ({
15
- getRequestClient: () => ({ saveCellConfig }),
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
- store.set(
22
- notebookAtom,
23
- MockNotebook.notebookState({
24
- cellData: {
25
- [cellId]: { config: { expand_output: opts.expandOutput ?? false } },
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 { result } = renderHook(() => useExpandedOutput(cellId), {
32
- wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
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, result };
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
- expect(setup({ mode: "edit" }).result.current[0]).toBe(false);
48
- expect(setup({ mode: "edit", expandOutput: true }).result.current[0]).toBe(
49
- true,
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, store, result } = setup({ mode: "edit" });
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(configOf(store, cellId).expand_output).toBe(true);
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, store, result } = setup({
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(configOf(store, cellId).expand_output).toBe(false);
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, store, result } = setup({ mode: "read" });
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(configOf(store, cellId).expand_output).toBe(false);
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 { result } = setup({ mode: "read", expandOutput: true });
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 = CellId.create();
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: ({ children }) => <Provider store={store}>{children}</Provider>,
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(configOf(store, cellId).expand_output).toBe(false);
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 { notebookAtom } from "./cells";
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
- * Scroll to the first cell that is currently in "running" state.
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",