@marimo-team/islands 0.18.2 → 0.18.4

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 (43) hide show
  1. package/dist/{constants-DWBOe162.js → constants-D_G8vnDk.js} +5 -4
  2. package/dist/{formats-7RSCCoSI.js → formats-Bi_tbdwB.js} +21 -22
  3. package/dist/{glide-data-editor-D-Ia_Jsv.js → glide-data-editor-DXF8E-QD.js} +2 -2
  4. package/dist/main.js +280 -148
  5. package/dist/style.css +1 -1
  6. package/dist/{types-Dunk85GC.js → types-DclGb0Yh.js} +1 -1
  7. package/dist/{vega-component-kU4hFYYJ.js → vega-component-BFcH2SqR.js} +8 -8
  8. package/package.json +1 -1
  9. package/src/components/app-config/user-config-form.tsx +14 -1
  10. package/src/components/data-table/context-menu.tsx +7 -3
  11. package/src/components/data-table/filter-pills.tsx +2 -1
  12. package/src/components/data-table/filters.ts +11 -2
  13. package/src/components/editor/cell/CreateCellButton.tsx +5 -3
  14. package/src/components/editor/cell/collapse.tsx +2 -2
  15. package/src/components/editor/chrome/components/contribute-snippet-button.tsx +22 -103
  16. package/src/components/editor/controls/duplicate-shortcut-banner.tsx +50 -0
  17. package/src/components/editor/controls/keyboard-shortcuts.tsx +25 -2
  18. package/src/components/editor/notebook-banner.tsx +1 -1
  19. package/src/components/editor/notebook-cell.tsx +4 -3
  20. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +6 -6
  21. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +3 -3
  22. package/src/components/pages/home-page.tsx +6 -0
  23. package/src/components/scratchpad/scratchpad.tsx +2 -1
  24. package/src/core/constants.ts +10 -0
  25. package/src/core/layout/useTogglePresenting.ts +69 -25
  26. package/src/core/state/__mocks__/mocks.ts +1 -0
  27. package/src/hooks/__tests__/useDuplicateShortcuts.test.ts +449 -0
  28. package/src/hooks/useDuplicateShortcuts.ts +145 -0
  29. package/src/plugins/impl/NumberPlugin.tsx +1 -1
  30. package/src/plugins/impl/__tests__/NumberPlugin.test.tsx +1 -1
  31. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +67 -47
  32. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +2 -57
  33. package/src/plugins/impl/anywidget/__tests__/model.test.ts +23 -19
  34. package/src/plugins/impl/anywidget/model.ts +68 -41
  35. package/src/plugins/impl/data-frames/utils/__tests__/operators.test.ts +2 -0
  36. package/src/plugins/impl/data-frames/utils/operators.ts +1 -0
  37. package/src/plugins/impl/vega/vega.css +5 -0
  38. package/src/plugins/layout/NavigationMenuPlugin.tsx +24 -22
  39. package/src/plugins/layout/StatPlugin.tsx +43 -23
  40. package/src/utils/__tests__/data-views.test.ts +495 -13
  41. package/src/utils/__tests__/json-parser.test.ts +1 -1
  42. package/src/utils/data-views.ts +134 -16
  43. package/src/utils/json/base64.ts +8 -0
@@ -0,0 +1,145 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+
3
+ import { useMemo } from "react";
4
+ import type {
5
+ HotkeyAction,
6
+ HotkeyGroup,
7
+ HotkeyProvider,
8
+ } from "@/core/hotkeys/hotkeys";
9
+
10
+ export interface DuplicateGroup {
11
+ key: string;
12
+ actions: { action: HotkeyAction; name: string }[];
13
+ }
14
+
15
+ export interface DuplicateShortcutsResult {
16
+ /** All groups of duplicate shortcuts */
17
+ duplicates: DuplicateGroup[];
18
+ /** Check if a specific action has duplicate shortcuts */
19
+ hasDuplicate: (action: HotkeyAction) => boolean;
20
+ /** Get all actions that share the same shortcut as the given action */
21
+ getDuplicatesFor: (action: HotkeyAction) => HotkeyAction[];
22
+ }
23
+
24
+ /**
25
+ * Normalizes a keyboard shortcut key for comparison.
26
+ * - Converts to lowercase
27
+ * - Replaces + with - for consistent comparison
28
+ * - Trims whitespace
29
+ */
30
+ export function normalizeShortcutKey(key: string): string {
31
+ return key.toLowerCase().replaceAll("+", "-").trim();
32
+ }
33
+
34
+ /**
35
+ * Detects duplicate keyboard shortcuts in a hotkey provider.
36
+ * Returns information about which shortcuts are duplicated and provides utilities
37
+ * to check if specific actions have duplicates.
38
+ *
39
+ * This is a pure function that can be tested independently of React.
40
+ *
41
+ * @param hotkeys - The hotkey provider to check for duplicates
42
+ * @param ignoreGroup - Optional group to exclude from duplicate detection (e.g., "Markdown")
43
+ */
44
+ export function findDuplicateShortcuts(
45
+ hotkeys: HotkeyProvider,
46
+ ignoreGroup?: HotkeyGroup,
47
+ ): DuplicateShortcutsResult {
48
+ // Get all groups to check for ignored actions
49
+ const groups = hotkeys.getHotkeyGroups();
50
+ const ignoredActions = ignoreGroup
51
+ ? new Set(groups[ignoreGroup] || [])
52
+ : new Set();
53
+
54
+ // Group actions by their key binding
55
+ const keyMap = new Map<string, { action: HotkeyAction; name: string }[]>();
56
+
57
+ for (const action of hotkeys.iterate()) {
58
+ // Skip actions in ignored groups
59
+ if (ignoredActions.has(action)) {
60
+ continue;
61
+ }
62
+
63
+ const hotkey = hotkeys.getHotkey(action);
64
+
65
+ // Skip empty keys (not set)
66
+ if (!hotkey.key || hotkey.key.trim() === "") {
67
+ continue;
68
+ }
69
+
70
+ const normalizedKey = normalizeShortcutKey(hotkey.key);
71
+
72
+ if (!keyMap.has(normalizedKey)) {
73
+ keyMap.set(normalizedKey, []);
74
+ }
75
+
76
+ const existing = keyMap.get(normalizedKey);
77
+ if (existing) {
78
+ existing.push({
79
+ action,
80
+ name: hotkey.name,
81
+ });
82
+ }
83
+ }
84
+
85
+ // Filter to only groups with duplicates (more than one action per key)
86
+ const duplicates: DuplicateGroup[] = [];
87
+ const duplicateActionSet = new Set<HotkeyAction>();
88
+
89
+ for (const [key, actions] of keyMap.entries()) {
90
+ if (actions.length > 1) {
91
+ duplicates.push({ key, actions });
92
+ for (const { action } of actions) {
93
+ duplicateActionSet.add(action);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Helper to check if an action has duplicates
99
+ const hasDuplicate = (action: HotkeyAction): boolean => {
100
+ return duplicateActionSet.has(action);
101
+ };
102
+
103
+ // Helper to get all duplicates for a specific action
104
+ const getDuplicatesFor = (action: HotkeyAction): HotkeyAction[] => {
105
+ const hotkey = hotkeys.getHotkey(action);
106
+ if (!hotkey.key || hotkey.key.trim() === "") {
107
+ return [];
108
+ }
109
+
110
+ const normalizedKey = normalizeShortcutKey(hotkey.key);
111
+
112
+ const group = duplicates.find((d) => d.key === normalizedKey);
113
+ if (!group || group.actions.length <= 1) {
114
+ return [];
115
+ }
116
+
117
+ return group.actions
118
+ .filter((a) => a.action !== action)
119
+ .map((a) => a.action);
120
+ };
121
+
122
+ return {
123
+ duplicates,
124
+ hasDuplicate,
125
+ getDuplicatesFor,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Hook to detect duplicate keyboard shortcuts.
131
+ * Returns information about which shortcuts are duplicated and provides utilities
132
+ * to check if specific actions have duplicates.
133
+ *
134
+ * @param hotkeys - The hotkey provider to check for duplicates
135
+ * @param ignoreGroup - Optional group to exclude from duplicate detection (e.g., "Markdown")
136
+ */
137
+ export function useDuplicateShortcuts(
138
+ hotkeys: HotkeyProvider,
139
+ ignoreGroup?: HotkeyGroup,
140
+ ): DuplicateShortcutsResult {
141
+ return useMemo(
142
+ () => findDuplicateShortcuts(hotkeys, ignoreGroup),
143
+ [hotkeys, ignoreGroup],
144
+ );
145
+ }
@@ -79,7 +79,7 @@ const NumberComponent = (props: NumberComponentProps): JSX.Element => {
79
79
  // This needs to be `?? NaN` since `?? undefined` makes uncontrolled component
80
80
  // and can lead to leaving the old value in forms (https://github.com/marimo-team/marimo/issues/7352)
81
81
  // We out NaNs later
82
- value={value ?? NaN}
82
+ value={value ?? Number.NaN}
83
83
  step={props.step}
84
84
  onChange={handleChange}
85
85
  id={id}
@@ -113,7 +113,7 @@ describe("NumberPlugin", () => {
113
113
  z.infer<(typeof plugin)["validator"]>
114
114
  > = {
115
115
  host,
116
- value: NaN,
116
+ value: Number.NaN,
117
117
  setValue,
118
118
  data: {
119
119
  start: 0,
@@ -2,8 +2,9 @@
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
4
  import type { AnyWidget, Experimental } from "@anywidget/types";
5
- import { get, isEqual, set } from "lodash-es";
5
+ import { isEqual } from "lodash-es";
6
6
  import { useEffect, useMemo, useRef } from "react";
7
+ import useEvent from "react-use-event-hook";
7
8
  import { z } from "zod";
8
9
  import { MarimoIncomingMessageEvent } from "@/core/dom/events";
9
10
  import { asRemoteURL } from "@/core/runtime/config";
@@ -17,10 +18,13 @@ import { createPlugin } from "@/plugins/core/builder";
17
18
  import { rpc } from "@/plugins/core/rpc";
18
19
  import type { IPluginProps } from "@/plugins/types";
19
20
  import {
20
- type Base64String,
21
- byteStringToBinary,
22
- typedAtob,
23
- } from "@/utils/json/base64";
21
+ decodeFromWire,
22
+ isWireFormat,
23
+ serializeBuffersToBase64,
24
+ type WireFormat,
25
+ } from "@/utils/data-views";
26
+ import { prettyError } from "@/utils/errors";
27
+ import type { Base64String } from "@/utils/json/base64";
24
28
  import { Logger } from "@/utils/Logger";
25
29
  import { ErrorBanner } from "../common/error-banner";
26
30
  import { MODEL_MANAGER, Model } from "./model";
@@ -29,44 +33,56 @@ interface Data {
29
33
  jsUrl: string;
30
34
  jsHash: string;
31
35
  css?: string | null;
32
- bufferPaths?: (string | number)[][] | null;
33
- initialValue: T;
34
36
  }
35
37
 
36
- type T = Record<string, any>;
38
+ type T = Record<string, unknown>;
37
39
 
38
- // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
39
40
  type PluginFunctions = {
40
- send_to_widget: <T>(req: { content?: any }) => Promise<null | undefined>;
41
+ send_to_widget: <T>(req: {
42
+ content: unknown;
43
+ buffers: Base64String[];
44
+ }) => Promise<null | undefined>;
41
45
  };
42
46
 
43
- export const AnyWidgetPlugin = createPlugin<T>("marimo-anywidget")
47
+ export const AnyWidgetPlugin = createPlugin<WireFormat<T>>("marimo-anywidget")
44
48
  .withData(
45
49
  z.object({
46
50
  jsUrl: z.string(),
47
51
  jsHash: z.string(),
48
52
  css: z.string().nullish(),
49
- bufferPaths: z
50
- .array(z.array(z.union([z.string(), z.number()])))
51
- .nullish(),
52
- initialValue: z.object({}).passthrough(),
53
53
  }),
54
54
  )
55
55
  .withFunctions<PluginFunctions>({
56
56
  send_to_widget: rpc
57
- .input(z.object({ content: z.any() }))
57
+ .input(
58
+ z.object({
59
+ content: z.unknown(),
60
+ buffers: z.array(z.string().transform((v) => v as Base64String)),
61
+ }),
62
+ )
58
63
  .output(z.null().optional()),
59
64
  })
60
65
  .renderer((props) => <AnyWidgetSlot {...props} />);
61
66
 
62
- type Props = IPluginProps<T, Data, PluginFunctions>;
63
-
64
- const AnyWidgetSlot = (props: Props) => {
65
- const { css, jsUrl, jsHash, bufferPaths } = props.data;
67
+ const AnyWidgetSlot = (
68
+ props: IPluginProps<WireFormat<T>, Data, PluginFunctions>,
69
+ ) => {
70
+ const { css, jsUrl, jsHash } = props.data;
66
71
 
72
+ // Decode wire format { state, bufferPaths, buffers } to state with DataViews
67
73
  const valueWithBuffers = useMemo(() => {
68
- return resolveInitialValue(props.value, bufferPaths ?? []);
69
- }, [props.value, bufferPaths]);
74
+ if (isWireFormat(props.value)) {
75
+ const decoded = decodeFromWire(props.value);
76
+ Logger.debug("AnyWidget decoded wire format:", {
77
+ bufferPaths: props.value.bufferPaths,
78
+ buffersCount: props.value.buffers?.length,
79
+ decodedKeys: Object.keys(decoded),
80
+ });
81
+ return decoded;
82
+ }
83
+ Logger.warn("AnyWidget value is not wire format:", props.value);
84
+ return props.value;
85
+ }, [props.value]);
70
86
 
71
87
  // JS is an ESM file with a render function on it
72
88
  // export function render({ model, el }) {
@@ -135,6 +151,12 @@ const AnyWidgetSlot = (props: Props) => {
135
151
  };
136
152
  }, [css, props.host]);
137
153
 
154
+ // Wrap setValue to serialize DataViews back to base64 before sending
155
+ // Structure matches ipywidgets protocol: { state, bufferPaths, buffers }
156
+ const wrappedSetValue = useEvent((partialValue: Partial<T>) =>
157
+ props.setValue(serializeBuffersToBase64(partialValue)),
158
+ );
159
+
138
160
  if (error) {
139
161
  return <ErrorBanner error={error} />;
140
162
  }
@@ -162,6 +184,7 @@ const AnyWidgetSlot = (props: Props) => {
162
184
  key={key}
163
185
  {...props}
164
186
  widget={module.default}
187
+ setValue={wrappedSetValue}
165
188
  value={valueWithBuffers}
166
189
  />
167
190
  );
@@ -191,10 +214,19 @@ async function runAnyWidgetModule(
191
214
  const widget =
192
215
  typeof widgetDef === "function" ? await widgetDef() : widgetDef;
193
216
  await widget.initialize?.({ model, experimental });
194
- const unsub = await widget.render?.({ model, el, experimental });
195
- return () => {
196
- unsub?.();
197
- };
217
+ try {
218
+ const unsub = await widget.render?.({ model, el, experimental });
219
+ return () => {
220
+ unsub?.();
221
+ };
222
+ } catch (error) {
223
+ Logger.error("Error rendering anywidget", error);
224
+ el.classList.add("text-error");
225
+ el.innerHTML = `Error rendering anywidget: ${prettyError(error)}`;
226
+ return () => {
227
+ // No-op
228
+ };
229
+ }
198
230
  }
199
231
 
200
232
  function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
@@ -218,6 +250,13 @@ function hasModelId(message: unknown): message is { model_id: string } {
218
250
  );
219
251
  }
220
252
 
253
+ interface Props
254
+ extends Omit<IPluginProps<T, Data, PluginFunctions>, "setValue"> {
255
+ widget: AnyWidget;
256
+ value: T;
257
+ setValue: (value: Partial<T>) => void;
258
+ }
259
+
221
260
  const LoadedSlot = ({
222
261
  value,
223
262
  setValue,
@@ -228,15 +267,9 @@ const LoadedSlot = ({
228
267
  }: Props & { widget: AnyWidget }) => {
229
268
  const htmlRef = useRef<HTMLDivElement>(null);
230
269
 
270
+ // value is already decoded from wire format
231
271
  const model = useRef<Model<T>>(
232
- new Model(
233
- // Merge the initial value with the current value
234
- // since we only send partial updates to the backend
235
- { ...data.initialValue, ...value },
236
- setValue,
237
- functions.send_to_widget,
238
- getDirtyFields(value, data.initialValue),
239
- ),
272
+ new Model(value, setValue, functions.send_to_widget, new Set()),
240
273
  );
241
274
 
242
275
  // Listen to incoming messages
@@ -289,16 +322,3 @@ export const visibleForTesting = {
289
322
  isAnyWidgetModule,
290
323
  getDirtyFields,
291
324
  };
292
-
293
- export function resolveInitialValue(
294
- raw: Record<string, any>,
295
- bufferPaths: readonly (readonly (string | number)[])[],
296
- ) {
297
- const out = structuredClone(raw);
298
- for (const bufferPath of bufferPaths) {
299
- const base64String: Base64String = get(raw, bufferPath);
300
- const bytes = byteStringToBinary(typedAtob(base64String));
301
- set(out, bufferPath, new DataView(bytes.buffer));
302
- }
303
- return out;
304
- }
@@ -5,11 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { TestUtils } from "@/__tests__/test-helpers";
6
6
  import type { UIElementId } from "@/core/cells/ids";
7
7
  import { MarimoIncomingMessageEvent } from "@/core/dom/events";
8
- import {
9
- getDirtyFields,
10
- resolveInitialValue,
11
- visibleForTesting,
12
- } from "../AnyWidgetPlugin";
8
+ import { getDirtyFields, visibleForTesting } from "../AnyWidgetPlugin";
13
9
  import { Model } from "../model";
14
10
 
15
11
  const { LoadedSlot } = visibleForTesting;
@@ -132,6 +128,7 @@ describe("LoadedSlot", () => {
132
128
  message: {
133
129
  method: "update",
134
130
  state: { count: 10 },
131
+ buffer_paths: [],
135
132
  },
136
133
  buffers: [],
137
134
  },
@@ -183,55 +180,3 @@ describe("LoadedSlot", () => {
183
180
  });
184
181
  });
185
182
  });
186
-
187
- describe("resolveInitialValue", () => {
188
- it("should convert base64 strings to DataView at specified paths", () => {
189
- const result = resolveInitialValue(
190
- {
191
- a: 10,
192
- b: "aGVsbG8=", // "hello" in base64
193
- c: [1, "d29ybGQ="], // "world" in base64
194
- d: {
195
- foo: "bWFyaW1vCg==", // "marimo" in base64
196
- baz: 20,
197
- },
198
- },
199
- [["b"], ["c", 1], ["d", "foo"]],
200
- );
201
-
202
- expect(result).toMatchInlineSnapshot(`
203
- {
204
- "a": 10,
205
- "b": DataView [
206
- 104,
207
- 101,
208
- 108,
209
- 108,
210
- 111,
211
- ],
212
- "c": [
213
- 1,
214
- DataView [
215
- 119,
216
- 111,
217
- 114,
218
- 108,
219
- 100,
220
- ],
221
- ],
222
- "d": {
223
- "baz": 20,
224
- "foo": DataView [
225
- 109,
226
- 97,
227
- 114,
228
- 105,
229
- 109,
230
- 111,
231
- 10,
232
- ],
233
- },
234
- }
235
- `);
236
- });
237
- });
@@ -9,6 +9,7 @@ import {
9
9
  vi,
10
10
  } from "vitest";
11
11
  import { TestUtils } from "@/__tests__/test-helpers";
12
+ import type { Base64String } from "@/utils/json/base64";
12
13
  import {
13
14
  type AnyWidgetMessage,
14
15
  handleWidgetMessage,
@@ -23,7 +24,10 @@ describe("Model", () => {
23
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
25
  let onChange: (value: any) => void;
25
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- let sendToWidget: (req: { content?: any }) => Promise<null | undefined>;
27
+ let sendToWidget: (req: {
28
+ content: unknown;
29
+ buffers: Base64String[];
30
+ }) => Promise<null | undefined>;
27
31
 
28
32
  beforeEach(() => {
29
33
  onChange = vi.fn();
@@ -72,7 +76,7 @@ describe("Model", () => {
72
76
  });
73
77
  });
74
78
 
75
- it("should send all dirty fields", () => {
79
+ it("should clear dirty fields after save", () => {
76
80
  model.set("foo", "new value");
77
81
  model.save_changes();
78
82
 
@@ -83,14 +87,13 @@ describe("Model", () => {
83
87
  model.set("bar", 456);
84
88
  model.save_changes();
85
89
 
90
+ // After clearing, only the newly changed field is sent
86
91
  expect(onChange).toHaveBeenCalledWith({
87
- foo: "new value",
88
92
  bar: 456,
89
93
  });
90
94
  });
91
95
 
92
- // Skip because we don't clear the dirty fields after save
93
- it.skip("should clear dirty fields after save", () => {
96
+ it("should not call onChange when no dirty fields", () => {
94
97
  model.set("foo", "new value");
95
98
  model.save_changes();
96
99
  model.save_changes(); // Second save should not call onChange
@@ -144,21 +147,16 @@ describe("Model", () => {
144
147
  const callback = vi.fn();
145
148
  model.send({ test: true }, callback);
146
149
 
147
- expect(sendToWidget).toHaveBeenCalledWith({ content: { test: true } });
150
+ expect(sendToWidget).toHaveBeenCalledWith({
151
+ content: {
152
+ state: { test: true },
153
+ bufferPaths: [],
154
+ },
155
+ buffers: [],
156
+ });
148
157
  await TestUtils.nextTick(); // flush
149
158
  expect(callback).toHaveBeenCalledWith(null);
150
159
  });
151
-
152
- it("should warn when buffers are provided", () => {
153
- const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {
154
- // noop
155
- });
156
- model.send({ test: true }, null, [new ArrayBuffer(8)]);
157
-
158
- expect(consoleSpy).toHaveBeenCalledWith(
159
- "buffers not supported in marimo anywidget.send",
160
- );
161
- });
162
160
  });
163
161
 
164
162
  describe("widget_manager", () => {
@@ -228,7 +226,11 @@ describe("Model", () => {
228
226
  it("should handle update messages", () => {
229
227
  model.receiveCustomMessage({
230
228
  method: "update",
231
- state: { foo: "updated", bar: 789 },
229
+ state: {
230
+ foo: "updated",
231
+ bar: 789,
232
+ },
233
+ buffer_paths: [],
232
234
  });
233
235
 
234
236
  expect(model.get("foo")).toBe("updated");
@@ -333,7 +335,9 @@ describe("ModelManager", () => {
333
335
 
334
336
  const updateMessage: AnyWidgetMessage = {
335
337
  method: "update",
336
- state: { count: 1 },
338
+ state: {
339
+ count: 1,
340
+ },
337
341
  buffer_paths: [],
338
342
  };
339
343