@marimo-team/islands 0.23.14-dev6 → 0.23.14-dev61

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 (171) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
  2. package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
  3. package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
  4. package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
  5. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  6. package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
  7. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  8. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  9. package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
  11. package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
  12. package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
  13. package/dist/main.js +3630 -1801
  14. package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
  15. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  16. package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
  17. package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
  18. package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
  19. package/dist/style.css +1 -1
  20. package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
  21. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
  22. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  23. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
  24. package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
  25. package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
  26. package/package.json +4 -4
  27. package/src/__mocks__/requests.ts +1 -0
  28. package/src/__tests__/CellStatus.test.tsx +1 -2
  29. package/src/components/app-config/user-config-form.tsx +52 -0
  30. package/src/components/chat/acp/agent-panel.tsx +35 -1
  31. package/src/components/chat/chat-panel.tsx +68 -29
  32. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  33. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  34. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  35. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  36. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  37. package/src/components/data-table/charts/charts.tsx +23 -1
  38. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  39. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  40. package/src/components/debugger/debugger-code.tsx +7 -2
  41. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  42. package/src/components/editor/cell/CellStatus.tsx +1 -20
  43. package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
  44. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  45. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  46. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  47. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  48. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  49. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  50. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  51. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  52. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  53. package/src/components/editor/errors/auto-fix.tsx +88 -34
  54. package/src/components/editor/errors/fix-mode.ts +1 -1
  55. package/src/components/editor/notebook-cell.tsx +7 -0
  56. package/src/components/editor/output/ImageOutput.tsx +20 -6
  57. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
  58. package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
  59. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  60. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  61. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  62. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  63. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  64. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  65. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  66. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  67. package/src/components/slides/minimap.tsx +7 -1
  68. package/src/components/slides/reveal-component.tsx +3 -0
  69. package/src/components/slides/slide-cell-view.tsx +10 -6
  70. package/src/components/slides/slide.tsx +16 -13
  71. package/src/components/tracing/tracing.tsx +2 -1
  72. package/src/core/ai/config.ts +2 -2
  73. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  74. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  75. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  76. package/src/core/ai/context/providers/datasource.ts +27 -2
  77. package/src/core/ai/context/providers/error.ts +226 -36
  78. package/src/core/ai/context/registry.ts +77 -43
  79. package/src/core/ai/state.ts +11 -0
  80. package/src/core/cells/__tests__/cell.test.ts +39 -0
  81. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  82. package/src/core/cells/cell.ts +5 -3
  83. package/src/core/cells/readonly-code-display.ts +35 -0
  84. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  85. package/src/core/codemirror/ai/resources.ts +15 -10
  86. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  87. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
  88. package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
  89. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  90. package/src/core/codemirror/cells/debugger-state.ts +133 -0
  91. package/src/core/codemirror/cells/extensions.ts +32 -4
  92. package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
  93. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  94. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  95. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  96. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  97. package/src/core/codemirror/utils.ts +15 -0
  98. package/src/core/config/__tests__/config-schema.test.ts +17 -0
  99. package/src/core/config/config-schema.ts +1 -0
  100. package/src/core/config/config.ts +10 -0
  101. package/src/core/config/feature-flag.tsx +4 -0
  102. package/src/core/edit-app.tsx +8 -5
  103. package/src/core/errors/errors.ts +2 -1
  104. package/src/core/islands/bootstrap.ts +4 -3
  105. package/src/core/islands/bridge.ts +1 -0
  106. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  107. package/src/core/lifecycle/render-policy.ts +125 -0
  108. package/src/core/mime.ts +11 -4
  109. package/src/core/network/__tests__/api.test.ts +17 -0
  110. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  111. package/src/core/network/api.ts +17 -6
  112. package/src/core/network/requests-lazy.ts +1 -0
  113. package/src/core/network/requests-network.ts +8 -0
  114. package/src/core/network/requests-static.ts +1 -0
  115. package/src/core/network/requests-toasting.tsx +10 -1
  116. package/src/core/network/types.ts +2 -0
  117. package/src/core/run-app.tsx +27 -23
  118. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  119. package/src/core/runtime/__tests__/runtime.test.ts +45 -0
  120. package/src/core/runtime/adapter.ts +182 -0
  121. package/src/core/runtime/runtime.ts +30 -16
  122. package/src/core/wasm/PyodideLoader.tsx +20 -62
  123. package/src/core/wasm/bridge.ts +13 -4
  124. package/src/core/wasm/state.ts +8 -19
  125. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
  126. package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
  127. package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
  128. package/src/core/websocket/transports/sse.ts +308 -0
  129. package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
  130. package/src/core/websocket/useWebSocket.tsx +12 -2
  131. package/src/css/app/codemirror.css +14 -0
  132. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  133. package/src/hooks/useDelayElapsed.ts +27 -0
  134. package/src/mount.tsx +3 -3
  135. package/src/plugins/core/RenderHTML.tsx +35 -1
  136. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  137. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  138. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
  139. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
  140. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  141. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  142. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  143. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  144. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  145. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  146. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  147. package/src/plugins/impl/anywidget/host.ts +54 -0
  148. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  149. package/src/plugins/impl/anywidget/model.ts +59 -239
  150. package/src/plugins/impl/anywidget/registry.ts +268 -0
  151. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  152. package/src/plugins/impl/anywidget/runtime.ts +422 -0
  153. package/src/plugins/impl/anywidget/types.ts +14 -0
  154. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  155. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  156. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  157. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  158. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  159. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  160. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  161. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  162. package/src/utils/errors.ts +15 -0
  163. package/src/utils/time.ts +20 -0
  164. package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
  165. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  166. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  167. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  168. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  169. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  170. package/src/core/wasm/__tests__/state.test.ts +0 -124
  171. package/src/plugins/impl/anywidget/schemas.ts +0 -32
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { EditorState, type Extension } from "@codemirror/state";
4
- import { keymap } from "@codemirror/view";
4
+ import { EditorView, keymap } from "@codemirror/view";
5
5
  import { describe, expect, test, vi } from "vitest";
6
6
  import { cellId } from "@/__tests__/branded";
7
7
  import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
@@ -163,6 +163,38 @@ test("auto_close_pairs: false removes closeBrackets keymaps", () => {
163
163
  expect(hasBracketPairHandler(withoutAutoClose)).toBe(false);
164
164
  });
165
165
 
166
+ test("go to definition falls through to lower-priority LSP keymap", () => {
167
+ const lspGoToDefinition = vi.fn(() => true);
168
+ const goToDefinitionKey = getOpts().hotkeys.getHotkey(
169
+ "cell.goToDefinition",
170
+ ).key;
171
+
172
+ const view = new EditorView({
173
+ state: EditorState.create({
174
+ doc: "parser.add_argument('--foo')",
175
+ selection: { anchor: "parser.".length },
176
+ extensions: [
177
+ ...setup(),
178
+ keymap.of([{ key: goToDefinitionKey, run: lspGoToDefinition }]),
179
+ ],
180
+ }),
181
+ parent: document.body,
182
+ });
183
+
184
+ try {
185
+ const event = new KeyboardEvent("keydown", {
186
+ key: goToDefinitionKey,
187
+ bubbles: true,
188
+ cancelable: true,
189
+ });
190
+ view.contentDOM.dispatchEvent(event);
191
+
192
+ expect(lspGoToDefinition).toHaveBeenCalledOnce();
193
+ } finally {
194
+ view.destroy();
195
+ }
196
+ });
197
+
166
198
  test("placeholder adds another extension", () => {
167
199
  const opts = getOpts();
168
200
  const withAI = new PythonLanguageAdapter()
@@ -11,6 +11,7 @@ import {
11
11
  resourceDecorations,
12
12
  resourceInputFilter,
13
13
  resourcesField,
14
+ resourceSync,
14
15
  resourceTheme,
15
16
  } from "@marimo-team/codemirror-mcp";
16
17
  import {
@@ -38,25 +39,28 @@ const NONE_RESOURCE_FORMAT_COMPLETION = {
38
39
  },
39
40
  };
40
41
 
42
+ function getRegistryResources(store: JotaiStore): Resource[] {
43
+ const registry = getAIContextRegistry(store);
44
+ const resources = registry.getAllItems();
45
+ if (resources.length === 0) {
46
+ return NONE_RESOURCE;
47
+ }
48
+ return resources;
49
+ }
50
+
41
51
  export function resourceExtension(opts: {
42
52
  language: Language;
43
53
  store: JotaiStore;
44
54
  onAddFiles?: (files: File[]) => void;
45
55
  }): Extension[] {
46
56
  const { language, store, onAddFiles } = opts;
57
+ const getResources = () => getRegistryResources(store);
47
58
 
48
59
  return [
49
60
  language.data.of({
50
61
  // Resource completion for static resources (variables, tables, etc.)
51
62
  autocomplete: resourceCompletion(
52
- async (): Promise<Resource[]> => {
53
- const registry = getAIContextRegistry(store);
54
- const resources = registry.getAllItems();
55
- if (resources.length === 0) {
56
- return NONE_RESOURCE;
57
- }
58
- return resources;
59
- },
63
+ async (): Promise<Resource[]> => getResources(),
60
64
  (resource) => {
61
65
  if (resource.type === NONE_RESOURCE_TYPE) {
62
66
  return NONE_RESOURCE_FORMAT_COMPLETION;
@@ -92,9 +96,10 @@ export function resourceExtension(opts: {
92
96
  : []),
93
97
  resourceDecorations,
94
98
  resourceInputFilter,
99
+ // Resolve @-mention chips for programmatic prefills.
100
+ resourceSync(getResources, { logger: Logger }),
95
101
  resourcesField.init(() => {
96
- const registry = getAIContextRegistry(store);
97
- const resources = registry.getAllItems();
102
+ const resources = getRegistryResources(store);
98
103
  return new Map(resources.map((resource) => [resource.uri, resource]));
99
104
  }),
100
105
  resourceTheme,
@@ -0,0 +1,185 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { python } from "@codemirror/lang-python";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { EditorView } from "@codemirror/view";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { cellId } from "@/__tests__/branded";
8
+ import type { CellId } from "@/core/cells/ids";
9
+ import { createMockObservable } from "@/core/state/__mocks__/mocks";
10
+ import type { Observable } from "@/core/state/observable";
11
+ import {
12
+ breakpointGutter,
13
+ debuggerLineHighlighter,
14
+ } from "../debugger-decorations";
15
+
16
+ vi.mock("../debugger-state", () => ({
17
+ toggleBreakpoint: vi.fn(),
18
+ }));
19
+
20
+ import { toggleBreakpoint } from "../debugger-state";
21
+
22
+ const CODE = `def my_function():
23
+ x = 1
24
+ y = 2
25
+ return x + y
26
+
27
+ result = my_function()`;
28
+
29
+ function createLineHighlighterEditor(
30
+ lineObservable: Observable<number | null>,
31
+ ) {
32
+ const state = EditorState.create({
33
+ doc: CODE,
34
+ extensions: [python(), debuggerLineHighlighter(lineObservable)],
35
+ });
36
+ return new EditorView({ state, parent: document.body });
37
+ }
38
+
39
+ function createBreakpointGutterEditor(
40
+ cid: CellId,
41
+ breakpointsObservable: Observable<ReadonlySet<number>>,
42
+ ) {
43
+ const state = EditorState.create({
44
+ doc: CODE,
45
+ extensions: [python(), breakpointGutter(cid, breakpointsObservable)],
46
+ });
47
+ return new EditorView({ state, parent: document.body });
48
+ }
49
+
50
+ /** Flush the microtask queue and apply the resulting state changes to `view`. */
51
+ async function flush(view: EditorView): Promise<void> {
52
+ await Promise.resolve();
53
+ view.dispatch({});
54
+ }
55
+
56
+ /**
57
+ * Count rendered breakpoint markers, excluding the always-present hidden
58
+ * spacer element (`initialSpacer`) that isn't an actual breakpoint.
59
+ */
60
+ function visibleMarkerCount(view: EditorView): number {
61
+ const gutterElement = view.dom.querySelector(".cm-breakpoint-gutter");
62
+ if (!gutterElement) {
63
+ return 0;
64
+ }
65
+ return gutterElement.querySelectorAll(
66
+ '.cm-gutterElement:not([style*="visibility: hidden"]) .cm-breakpoint-marker',
67
+ ).length;
68
+ }
69
+
70
+ describe("debugger-decorations", () => {
71
+ let view: EditorView | null = null;
72
+
73
+ afterEach(() => {
74
+ if (view) {
75
+ view.destroy();
76
+ view = null;
77
+ }
78
+ document.body.innerHTML = "";
79
+ vi.clearAllMocks();
80
+ });
81
+
82
+ describe("debuggerLineHighlighter", () => {
83
+ it("renders no highlight when the observable starts null", () => {
84
+ const lineObservable = createMockObservable<number | null>(null);
85
+ view = createLineHighlighterEditor(lineObservable);
86
+
87
+ expect(view.dom.querySelector(".cm-debugger-current-line")).toBeNull();
88
+ });
89
+
90
+ it("highlights the line reported by the observable", () => {
91
+ const lineObservable = createMockObservable<number | null>(null);
92
+ view = createLineHighlighterEditor(lineObservable);
93
+
94
+ lineObservable.set(2);
95
+ view.dispatch({});
96
+
97
+ expect(
98
+ view.dom.querySelector(".cm-debugger-current-line"),
99
+ ).not.toBeNull();
100
+ });
101
+
102
+ it("clears the highlight when the line is set back to null", () => {
103
+ const lineObservable = createMockObservable<number | null>(2);
104
+ view = createLineHighlighterEditor(lineObservable);
105
+ view.dispatch({});
106
+ expect(
107
+ view.dom.querySelector(".cm-debugger-current-line"),
108
+ ).not.toBeNull();
109
+
110
+ lineObservable.set(null);
111
+ view.dispatch({});
112
+
113
+ expect(view.dom.querySelector(".cm-debugger-current-line")).toBeNull();
114
+ });
115
+
116
+ it("does not throw for an out-of-range line", () => {
117
+ const lineObservable = createMockObservable<number | null>(null);
118
+ view = createLineHighlighterEditor(lineObservable);
119
+
120
+ expect(() => {
121
+ lineObservable.set(9999);
122
+ view?.dispatch({});
123
+ }).not.toThrow();
124
+ expect(view.dom.querySelector(".cm-debugger-current-line")).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe("breakpointGutter", () => {
129
+ it("renders no markers when there are no breakpoints", () => {
130
+ const cid = cellId("cell1");
131
+ const breakpointsObservable = createMockObservable<ReadonlySet<number>>(
132
+ new Set(),
133
+ );
134
+ view = createBreakpointGutterEditor(cid, breakpointsObservable);
135
+
136
+ expect(visibleMarkerCount(view)).toBe(0);
137
+ });
138
+
139
+ it("syncs pre-existing breakpoints on mount", async () => {
140
+ const cid = cellId("cell1");
141
+ const breakpointsObservable = createMockObservable<ReadonlySet<number>>(
142
+ new Set([2]),
143
+ );
144
+ view = createBreakpointGutterEditor(cid, breakpointsObservable);
145
+ await flush(view); // initial sync is deferred to a microtask
146
+
147
+ expect(visibleMarkerCount(view)).toBe(1);
148
+ });
149
+
150
+ it("adds a marker when the observable reports a new breakpoint", () => {
151
+ const cid = cellId("cell1");
152
+ const breakpointsObservable = createMockObservable<ReadonlySet<number>>(
153
+ new Set(),
154
+ );
155
+ view = createBreakpointGutterEditor(cid, breakpointsObservable);
156
+
157
+ breakpointsObservable.set(new Set([1, 3]));
158
+ view.dispatch({});
159
+
160
+ expect(visibleMarkerCount(view)).toBe(2);
161
+ });
162
+
163
+ it("toggles a breakpoint when an existing marker is clicked", async () => {
164
+ const cid = cellId("cell1");
165
+ // A single breakpoint on line 1 renders exactly one non-spacer
166
+ // gutter element, so the click lands unambiguously on that line.
167
+ const breakpointsObservable = createMockObservable<ReadonlySet<number>>(
168
+ new Set([1]),
169
+ );
170
+ view = createBreakpointGutterEditor(cid, breakpointsObservable);
171
+ await flush(view);
172
+
173
+ const gutterElement = view.dom.querySelector(".cm-breakpoint-gutter");
174
+ const lineElement = gutterElement?.querySelector(
175
+ '.cm-gutterElement:not([style*="visibility: hidden"])',
176
+ );
177
+ expect(lineElement).not.toBeNull();
178
+ lineElement?.dispatchEvent(
179
+ new MouseEvent("mousedown", { bubbles: true }),
180
+ );
181
+
182
+ expect(toggleBreakpoint).toHaveBeenCalledWith(cid, 1);
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,224 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { MockRequestClient } from "@/__mocks__/requests";
5
+ import { cellId } from "@/__tests__/branded";
6
+ import { store } from "@/core/state/jotai";
7
+ import {
8
+ activeLineAtom,
9
+ breakpointsAtom,
10
+ clearCellBreakpoints,
11
+ createActiveLineInfoAtom,
12
+ createCellBreakpointsAtom,
13
+ createDebuggerLineAtom,
14
+ resyncBreakpoints,
15
+ setActiveLine,
16
+ toggleBreakpoint,
17
+ } from "../debugger-state";
18
+
19
+ const mockRequestClient = MockRequestClient.create();
20
+ vi.mock("@/core/network/requests", () => ({
21
+ getRequestClient: () => mockRequestClient,
22
+ }));
23
+
24
+ const cell1 = cellId("cell1");
25
+ const cell2 = cellId("cell2");
26
+
27
+ describe("debugger-state", () => {
28
+ beforeEach(() => {
29
+ store.set(breakpointsAtom, new Map());
30
+ store.set(activeLineAtom, null);
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ describe("toggleBreakpoint", () => {
35
+ it("adds a breakpoint and syncs the full set to the kernel", () => {
36
+ toggleBreakpoint(cell1, 3);
37
+
38
+ expect(store.get(breakpointsAtom).get(cell1)).toEqual(new Set([3]));
39
+ expect(mockRequestClient.sendSetBreakpoints).toHaveBeenCalledWith({
40
+ breakpoints: { [cell1]: [3] },
41
+ });
42
+ });
43
+
44
+ it("removes a breakpoint on second toggle and drops the cell once empty", () => {
45
+ toggleBreakpoint(cell1, 3);
46
+ toggleBreakpoint(cell1, 3);
47
+
48
+ expect(store.get(breakpointsAtom).has(cell1)).toBe(false);
49
+ expect(mockRequestClient.sendSetBreakpoints).toHaveBeenLastCalledWith({
50
+ breakpoints: {},
51
+ });
52
+ });
53
+
54
+ it("sends lines sorted ascending for a cell with multiple breakpoints", () => {
55
+ toggleBreakpoint(cell1, 5);
56
+ toggleBreakpoint(cell1, 2);
57
+
58
+ expect(mockRequestClient.sendSetBreakpoints).toHaveBeenLastCalledWith({
59
+ breakpoints: { [cell1]: [2, 5] },
60
+ });
61
+ });
62
+
63
+ it("keeps breakpoints for other cells independent", () => {
64
+ toggleBreakpoint(cell1, 1);
65
+ toggleBreakpoint(cell2, 2);
66
+
67
+ expect(store.get(breakpointsAtom).get(cell1)).toEqual(new Set([1]));
68
+ expect(store.get(breakpointsAtom).get(cell2)).toEqual(new Set([2]));
69
+ });
70
+ });
71
+
72
+ describe("clearCellBreakpoints", () => {
73
+ it("removes all breakpoints for a cell and syncs", () => {
74
+ toggleBreakpoint(cell1, 1);
75
+ toggleBreakpoint(cell1, 2);
76
+ vi.clearAllMocks();
77
+
78
+ clearCellBreakpoints(cell1);
79
+
80
+ expect(store.get(breakpointsAtom).has(cell1)).toBe(false);
81
+ expect(mockRequestClient.sendSetBreakpoints).toHaveBeenCalledWith({
82
+ breakpoints: {},
83
+ });
84
+ });
85
+
86
+ it("is a no-op when the cell has no breakpoints", () => {
87
+ clearCellBreakpoints(cell1);
88
+
89
+ expect(mockRequestClient.sendSetBreakpoints).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("resyncBreakpoints", () => {
94
+ it("re-sends the current breakpoint set", () => {
95
+ toggleBreakpoint(cell1, 4);
96
+ vi.clearAllMocks();
97
+
98
+ resyncBreakpoints();
99
+
100
+ expect(mockRequestClient.sendSetBreakpoints).toHaveBeenCalledWith({
101
+ breakpoints: { [cell1]: [4] },
102
+ });
103
+ });
104
+
105
+ it("does not send when there are no breakpoints", () => {
106
+ resyncBreakpoints();
107
+
108
+ expect(mockRequestClient.sendSetBreakpoints).not.toHaveBeenCalled();
109
+ });
110
+ });
111
+
112
+ describe("createCellBreakpointsAtom", () => {
113
+ it("returns the empty set for a cell with no breakpoints", () => {
114
+ const atom = createCellBreakpointsAtom(cell1);
115
+ expect(store.get(atom)).toEqual(new Set());
116
+ });
117
+
118
+ it("reflects breakpoints for its own cell only", () => {
119
+ toggleBreakpoint(cell1, 7);
120
+ const cell1Atom = createCellBreakpointsAtom(cell1);
121
+ const cell2Atom = createCellBreakpointsAtom(cell2);
122
+
123
+ expect(store.get(cell1Atom)).toEqual(new Set([7]));
124
+ expect(store.get(cell2Atom)).toEqual(new Set());
125
+ });
126
+ });
127
+
128
+ describe("createDebuggerLineAtom", () => {
129
+ it("returns null when no line is set", () => {
130
+ const atom = createDebuggerLineAtom(cell1);
131
+ expect(store.get(atom)).toBeNull();
132
+ });
133
+
134
+ it("returns the line only for the matching cell", () => {
135
+ store.set(activeLineAtom, { cellId: cell1, line: 10, startedAtMs: 0 });
136
+
137
+ expect(store.get(createDebuggerLineAtom(cell1))).toBe(10);
138
+ expect(store.get(createDebuggerLineAtom(cell2))).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("setActiveLine", () => {
143
+ beforeEach(() => {
144
+ vi.useFakeTimers();
145
+ });
146
+
147
+ afterEach(() => {
148
+ vi.useRealTimers();
149
+ });
150
+
151
+ it("stamps startedAtMs when the line is first set", () => {
152
+ vi.setSystemTime(1000);
153
+ setActiveLine({ cellId: cell1, line: 3 });
154
+
155
+ expect(store.get(activeLineAtom)).toEqual({
156
+ cellId: cell1,
157
+ line: 3,
158
+ startedAtMs: 1000,
159
+ });
160
+ });
161
+
162
+ it("preserves startedAtMs when the same line is set again", () => {
163
+ vi.setSystemTime(1000);
164
+ setActiveLine({ cellId: cell1, line: 3 });
165
+ vi.setSystemTime(2000);
166
+ setActiveLine({ cellId: cell1, line: 3 });
167
+
168
+ expect(store.get(activeLineAtom)).toEqual({
169
+ cellId: cell1,
170
+ line: 3,
171
+ startedAtMs: 1000,
172
+ });
173
+ });
174
+
175
+ it("resets startedAtMs when the line changes", () => {
176
+ vi.setSystemTime(1000);
177
+ setActiveLine({ cellId: cell1, line: 3 });
178
+ vi.setSystemTime(2000);
179
+ setActiveLine({ cellId: cell1, line: 4 });
180
+
181
+ expect(store.get(activeLineAtom)).toEqual({
182
+ cellId: cell1,
183
+ line: 4,
184
+ startedAtMs: 2000,
185
+ });
186
+ });
187
+
188
+ it("resets startedAtMs when the cell changes", () => {
189
+ vi.setSystemTime(1000);
190
+ setActiveLine({ cellId: cell1, line: 3 });
191
+ vi.setSystemTime(2000);
192
+ setActiveLine({ cellId: cell2, line: 3 });
193
+
194
+ expect(store.get(activeLineAtom)).toEqual({
195
+ cellId: cell2,
196
+ line: 3,
197
+ startedAtMs: 2000,
198
+ });
199
+ });
200
+
201
+ it("clears the active line on null", () => {
202
+ setActiveLine({ cellId: cell1, line: 3 });
203
+ setActiveLine(null);
204
+
205
+ expect(store.get(activeLineAtom)).toBeNull();
206
+ });
207
+ });
208
+
209
+ describe("createActiveLineInfoAtom", () => {
210
+ it("returns null when no line is set", () => {
211
+ expect(store.get(createActiveLineInfoAtom(cell1))).toBeNull();
212
+ });
213
+
214
+ it("returns line info only for the matching cell", () => {
215
+ store.set(activeLineAtom, { cellId: cell1, line: 10, startedAtMs: 42 });
216
+
217
+ expect(store.get(createActiveLineInfoAtom(cell1))).toEqual({
218
+ line: 10,
219
+ startedAtMs: 42,
220
+ });
221
+ expect(store.get(createActiveLineInfoAtom(cell2))).toBeNull();
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,126 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { python } from "@codemirror/lang-python";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { EditorView } from "@codemirror/view";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createMockObservable } from "@/core/state/__mocks__/mocks";
8
+ import type { Observable } from "@/core/state/observable";
9
+ import {
10
+ type ActiveLineInfo,
11
+ activeLineTimer,
12
+ } from "../line-timing-decorations";
13
+
14
+ type MockObservable = Observable<ActiveLineInfo | null> & {
15
+ set: (value: ActiveLineInfo | null) => void;
16
+ };
17
+
18
+ function createEditor(
19
+ content: string,
20
+ infoObservable: Observable<ActiveLineInfo | null>,
21
+ ) {
22
+ const state = EditorState.create({
23
+ doc: content,
24
+ extensions: [python(), activeLineTimer(infoObservable)],
25
+ });
26
+
27
+ return new EditorView({
28
+ state,
29
+ parent: document.body,
30
+ });
31
+ }
32
+
33
+ const CODE = `a = 1
34
+ b = 2
35
+ c = 3`;
36
+
37
+ describe("line-timing-decorations", () => {
38
+ let view: EditorView | null = null;
39
+ let observable: MockObservable;
40
+
41
+ beforeEach(() => {
42
+ vi.useFakeTimers();
43
+ vi.setSystemTime(10_000);
44
+ observable = createMockObservable<ActiveLineInfo | null>(null);
45
+ });
46
+
47
+ afterEach(() => {
48
+ if (view) {
49
+ view.destroy();
50
+ view = null;
51
+ }
52
+ document.body.innerHTML = "";
53
+ vi.useRealTimers();
54
+ });
55
+
56
+ it("highlights the active line and no other", () => {
57
+ view = createEditor(CODE, observable);
58
+
59
+ expect(view.dom.querySelector(".cm-timing-current-line")).toBeNull();
60
+
61
+ observable.set({ line: 2, startedAtMs: Date.now() });
62
+
63
+ const highlighted = view.dom.querySelectorAll(".cm-timing-current-line");
64
+ expect(highlighted).toHaveLength(1);
65
+ expect(highlighted[0].textContent).toContain("b = 2");
66
+ });
67
+
68
+ it("keeps the timer empty before the threshold and formats after", () => {
69
+ view = createEditor(CODE, observable);
70
+ observable.set({ line: 1, startedAtMs: Date.now() });
71
+
72
+ const timer = view.dom.querySelector(".cm-line-timer");
73
+ expect(timer).not.toBeNull();
74
+ expect(timer?.textContent).toBe("");
75
+
76
+ // Still below the 500ms threshold.
77
+ vi.advanceTimersByTime(250);
78
+ expect(timer?.textContent).toBe("");
79
+
80
+ // Past the threshold: shows the elapsed time and keeps ticking.
81
+ vi.advanceTimersByTime(500);
82
+ expect(timer?.textContent).toBe("750ms");
83
+
84
+ vi.advanceTimersByTime(1000);
85
+ expect(timer?.textContent).toBe("1.75s");
86
+ });
87
+
88
+ it("reuses the widget DOM node when startedAtMs is unchanged", () => {
89
+ view = createEditor(CODE, observable);
90
+ observable.set({ line: 1, startedAtMs: Date.now() });
91
+
92
+ const before = view.dom.querySelector(".cm-line-timer");
93
+ expect(before).not.toBeNull();
94
+
95
+ // A fresh info object for the same line and start time (e.g. a duplicate
96
+ // notification) must not recreate the widget.
97
+ observable.set({ line: 1, startedAtMs: 10_000 });
98
+
99
+ expect(view.dom.querySelector(".cm-line-timer")).toBe(before);
100
+ });
101
+
102
+ it("clears the timer interval when the widget is destroyed", () => {
103
+ const setIntervalSpy = vi.spyOn(window, "setInterval");
104
+ const clearIntervalSpy = vi.spyOn(window, "clearInterval");
105
+ view = createEditor(CODE, observable);
106
+ observable.set({ line: 1, startedAtMs: Date.now() });
107
+
108
+ expect(view.dom.querySelector(".cm-line-timer")).not.toBeNull();
109
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1);
110
+ const intervalId = setIntervalSpy.mock.results[0].value;
111
+
112
+ observable.set(null);
113
+
114
+ expect(view.dom.querySelector(".cm-line-timer")).toBeNull();
115
+ expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
116
+ });
117
+
118
+ it("renders nothing for an out-of-range line", () => {
119
+ view = createEditor(CODE, observable);
120
+
121
+ observable.set({ line: 100, startedAtMs: Date.now() });
122
+
123
+ expect(view.dom.querySelector(".cm-timing-current-line")).toBeNull();
124
+ expect(view.dom.querySelector(".cm-line-timer")).toBeNull();
125
+ });
126
+ });