@marimo-team/islands 0.18.5-dev168 → 0.18.5-dev171

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 (35) hide show
  1. package/dist/{Combination-CoheOLQf.js → Combination-33P1MEPK.js} +1 -1
  2. package/dist/{ConnectedDataExplorerComponent-BhnOd8mV.js → ConnectedDataExplorerComponent-BIfUtj_S.js} +9 -9
  3. package/dist/{any-language-editor-CO_tO4mX.js → any-language-editor-Bda9cY1_.js} +4 -4
  4. package/dist/{button-BE_o5IpN.js → button-BlF-78eJ.js} +1 -1
  5. package/dist/{check-Crt1N6cj.js → check-DDykH_Yi.js} +1 -1
  6. package/dist/{copy-BmWLlwa6.js → copy-B5nooU3m.js} +2 -2
  7. package/dist/{error-banner-DyX88bLT.js → error-banner-UH0Nxilf.js} +3 -3
  8. package/dist/{esm-CiSvoGHk.js → esm-D197NGQX.js} +4 -4
  9. package/dist/{glide-data-editor-tS-A6Szz.js → glide-data-editor-DWlk0mEY.js} +7 -7
  10. package/dist/{hotkeys-BUVs9ecz.js → hotkeys-C4e3s3sJ.js} +2 -2
  11. package/dist/{label-C3TPGdQ0.js → label-oKuiQuiM.js} +4 -4
  12. package/dist/{loader-Cn9P1Cko.js → loader-DH7xXi-E.js} +1 -1
  13. package/dist/main.js +113 -61
  14. package/dist/{mermaid-BAHK5egT.js → mermaid-JA6veDHv.js} +3 -3
  15. package/dist/{slides-component-oQmowhoJ.js → slides-component-BNbVrOMb.js} +2 -2
  16. package/dist/{spec-D-_Yj0lh.js → spec-hsYzGr6F.js} +5 -5
  17. package/dist/style.css +1 -1
  18. package/dist/{types-CJDsYooe.js → types-DEmfj_i8.js} +6 -6
  19. package/dist/{useAsyncData-CaouoMw5.js → useAsyncData-BGpae_uu.js} +1 -1
  20. package/dist/{useDeepCompareMemoize-B01JaKw2.js → useDeepCompareMemoize-D3uOrgqD.js} +5 -5
  21. package/dist/{useIframeCapabilities-oYhPeWtR.js → useIframeCapabilities-BsIPDupA.js} +1 -1
  22. package/dist/{useTheme-DLCDAdUO.js → useTheme-DdLjooMf.js} +1 -1
  23. package/dist/{vega-component-D36WQQq8.js → vega-component-C1FaaACt.js} +8 -8
  24. package/package.json +1 -1
  25. package/src/components/editor/actions/useNotebookActions.tsx +1 -1
  26. package/src/components/editor/chrome/panels/panel-context.tsx +34 -0
  27. package/src/components/editor/chrome/state.ts +30 -15
  28. package/src/components/editor/chrome/types.ts +67 -77
  29. package/src/components/editor/chrome/wrapper/app-chrome.tsx +216 -139
  30. package/src/components/editor/chrome/wrapper/sidebar.tsx +76 -43
  31. package/src/components/scratchpad/scratchpad.tsx +17 -4
  32. package/src/components/ui/reorderable-list.tsx +190 -31
  33. package/src/core/codemirror/cells/extensions.ts +7 -4
  34. package/src/core/hotkeys/__tests__/shortcuts.test.ts +61 -4
  35. package/src/core/hotkeys/shortcuts.ts +34 -2
@@ -2,7 +2,12 @@
2
2
 
3
3
  import type React from "react";
4
4
  import { useMemo } from "react";
5
- import { ListBox, ListBoxItem, useDragAndDrop } from "react-aria-components";
5
+ import {
6
+ type DropItem,
7
+ ListBox,
8
+ ListBoxItem,
9
+ useDragAndDrop,
10
+ } from "react-aria-components";
6
11
  import { Logger } from "@/utils/Logger";
7
12
  import {
8
13
  ContextMenu,
@@ -12,15 +17,37 @@ import {
12
17
  } from "./context-menu";
13
18
  import "./reorderable-list.css";
14
19
 
15
- export interface ReorderableListProps<T extends { id: string | number }> {
20
+ interface DragData<T> {
21
+ itemId: string;
22
+ sourceListId: string;
23
+ item: T;
24
+ }
25
+
26
+ function getDragMimeType(dragType: string): string {
27
+ return `application/x-reorderable-${dragType}`;
28
+ }
29
+
30
+ function parseDragData<T>(text: string): DragData<T> | null {
31
+ try {
32
+ return JSON.parse(text) as DragData<T>;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export interface ReorderableListProps<T> {
16
39
  /**
17
- * The current list of items
40
+ * The current list of items.
18
41
  */
19
42
  value: T[];
20
43
  /**
21
44
  * Callback when items are reordered
22
45
  */
23
46
  setValue: (items: T[]) => void;
47
+ /**
48
+ * Function to get a unique key for each item. Used for drag-drop and rendering.
49
+ */
50
+ getKey: (item: T) => string;
24
51
  /**
25
52
  * Render function for each item.
26
53
  * Note: Avoid interactive elements (buttons) inside - they break drag behavior.
@@ -50,49 +77,148 @@ export interface ReorderableListProps<T extends { id: string | number }> {
50
77
  * Additional class name for the list container
51
78
  */
52
79
  className?: string;
80
+ /**
81
+ * Configuration for cross-list drag-drop. When set, items can be dragged
82
+ * between lists that share the same `dragType`.
83
+ */
84
+ crossListDrag?: {
85
+ /** Identifier that links lists together - same dragType = can share items */
86
+ dragType: string;
87
+ /** Unique identifier for this list */
88
+ listId: string;
89
+ /**
90
+ * Callback when an item is received from another list.
91
+ * At this point, setValue has been called with the new item included,
92
+ * but the parent component may not have re-rendered yet.
93
+ * Use this to remove the item from the source list and handle any side effects.
94
+ */
95
+ onReceive: (item: T, fromListId: string, insertIndex: number) => void;
96
+ };
53
97
  }
54
98
 
55
99
  /**
56
- * A generic reorderable list component using react-aria-components and react-stately.
57
- * Items can be reordered via drag and drop.
100
+ * A generic reorderable list component using react-aria-components.
101
+ * Items can be reordered via drag and drop within the list.
102
+ *
103
+ * For cross-list drag-drop, set the same `dragType` on multiple lists
104
+ * and provide an `onReceive` callback to handle items dropped from other lists.
58
105
  *
59
106
  * @example
60
107
  * ```tsx
61
- * interface MyItem {
62
- * id: string;
63
- * name: string;
64
- * }
65
- *
66
- * const [items, setItems] = useState<MyItem[]>([...]);
67
- *
108
+ * // Single list reordering
68
109
  * <ReorderableList
69
110
  * value={items}
70
111
  * setValue={setItems}
112
+ * getKey={(item) => item.id}
71
113
  * renderItem={(item) => <div>{item.name}</div>}
72
- * ariaLabel="My reorderable list"
114
+ * />
115
+ *
116
+ * // Cross-list drag-drop
117
+ * <ReorderableList
118
+ * value={sidebarItems}
119
+ * setValue={setSidebarItems}
120
+ * getKey={(item) => item.type}
121
+ * renderItem={(item) => <div>{item.name}</div>}
122
+ * crossListDrag={{
123
+ * dragType: "panels",
124
+ * listId: "sidebar",
125
+ * onReceive: (item, fromListId) => {
126
+ * // Remove from source list
127
+ * setOtherItems(prev => prev.filter(i => i.type !== item.type));
128
+ * },
129
+ * }}
73
130
  * />
74
131
  * ```
75
132
  */
76
- export const ReorderableList = <T extends { id: string | number }>({
133
+ export const ReorderableList = <T extends object>({
77
134
  value,
78
135
  setValue,
136
+ getKey,
79
137
  renderItem,
80
138
  onAction,
81
139
  availableItems,
82
- getItemLabel = (item) => String(item.id),
140
+ getItemLabel,
83
141
  minItems = 1,
84
142
  ariaLabel = "Reorderable list",
85
143
  className,
144
+ crossListDrag,
86
145
  }: ReorderableListProps<T>) => {
146
+ const mimeType = crossListDrag
147
+ ? getDragMimeType(crossListDrag.dragType)
148
+ : null;
149
+ const onReceive = crossListDrag?.onReceive;
150
+
151
+ // Shared handler for cross-list drops
152
+ const handleCrossListDrop = async (
153
+ items: DropItem[],
154
+ insertIndex: number,
155
+ ) => {
156
+ if (!mimeType || !crossListDrag?.listId || !onReceive) {
157
+ return;
158
+ }
159
+
160
+ for (const dragItem of items) {
161
+ if (dragItem.kind !== "text" || !dragItem.types.has(mimeType)) {
162
+ continue;
163
+ }
164
+
165
+ const text = await dragItem.getText(mimeType);
166
+ const data = parseDragData<T>(text);
167
+ if (!data) {
168
+ continue;
169
+ }
170
+
171
+ // Only accept drops from different lists
172
+ if (data.sourceListId === crossListDrag.listId) {
173
+ continue;
174
+ }
175
+
176
+ // Skip if item already exists in this list
177
+ if (value.some((item) => getKey(item) === getKey(data.item))) {
178
+ continue;
179
+ }
180
+
181
+ // Add to this list and notify parent
182
+ setValue([
183
+ ...value.slice(0, insertIndex),
184
+ data.item,
185
+ ...value.slice(insertIndex),
186
+ ]);
187
+ onReceive(data.item, data.sourceListId, insertIndex);
188
+ }
189
+ };
190
+
87
191
  const { dragAndDropHooks } = useDragAndDrop<T>({
88
- getItems: (keys) => [...keys].map((key) => ({ "text/plain": String(key) })),
192
+ getItems: (keys) =>
193
+ [...keys].map((key) => {
194
+ const item = value.find((i) => getKey(i) === key);
195
+ const baseData: Record<string, string> = {
196
+ "text/plain": String(key),
197
+ };
198
+
199
+ // Add cross-list drag data if dragType is set
200
+ if (mimeType && crossListDrag?.listId && item) {
201
+ const dragData: DragData<T> = {
202
+ itemId: String(key),
203
+ sourceListId: crossListDrag.listId,
204
+ item,
205
+ };
206
+ baseData[mimeType] = JSON.stringify(dragData);
207
+ }
208
+
209
+ return baseData;
210
+ }),
211
+
212
+ // Accept drops from lists with the same dragType
213
+ acceptedDragTypes: mimeType ? [mimeType, "text/plain"] : ["text/plain"],
214
+
89
215
  onReorder(e) {
90
216
  const keySet = new Set(e.keys);
91
- const draggedItems = value.filter((item) => keySet.has(item.id));
92
- const remaining = value.filter((item) => !keySet.has(item.id));
217
+ const draggedItems = value.filter((item) => keySet.has(getKey(item)));
218
+ const remaining = value.filter((item) => !keySet.has(getKey(item)));
93
219
 
94
220
  const targetIndex = remaining.findIndex(
95
- (item) => item.id === e.target.key,
221
+ (item) => getKey(item) === e.target.key,
96
222
  );
97
223
  const insertIndex =
98
224
  e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
@@ -103,19 +229,34 @@ export const ReorderableList = <T extends { id: string | number }>({
103
229
  ...remaining.slice(insertIndex),
104
230
  ]);
105
231
  },
232
+
233
+ // Handle drops from other lists (on a specific item)
234
+ async onInsert(e) {
235
+ const targetIndex = value.findIndex(
236
+ (item) => getKey(item) === e.target.key,
237
+ );
238
+ const insertIndex =
239
+ e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
240
+ await handleCrossListDrop(e.items, insertIndex);
241
+ },
242
+
243
+ // Handle drops on empty list or root
244
+ async onRootDrop(e) {
245
+ await handleCrossListDrop(e.items, value.length);
246
+ },
106
247
  });
107
248
 
108
249
  // Track which items are currently in the list
109
- const currentItemIds = useMemo(
110
- () => new Set(value.map((item) => item.id)),
111
- [value],
250
+ const currentItemKeys = useMemo(
251
+ () => new Set(value.map((item) => getKey(item))),
252
+ [value, getKey],
112
253
  );
113
254
 
114
255
  const handleToggleItem = (item: T, isChecked: boolean) => {
115
256
  if (isChecked) {
116
257
  setValue([...value, item]);
117
258
  } else if (value.length > minItems) {
118
- setValue(value.filter((v) => v.id !== item.id));
259
+ setValue(value.filter((v) => getKey(v) !== getKey(item)));
119
260
  }
120
261
  };
121
262
 
@@ -124,12 +265,12 @@ export const ReorderableList = <T extends { id: string | number }>({
124
265
  return;
125
266
  }
126
267
 
127
- const item = value.find((i) => i.id === key);
268
+ const item = value.find((i) => getKey(i) === key);
128
269
 
129
270
  if (!item) {
130
271
  Logger.warn("handleAction: item not found for key", {
131
272
  key,
132
- availableIds: value.map((v) => v.id),
273
+ availableKeys: value.map((v) => getKey(v)),
133
274
  });
134
275
  return;
135
276
  }
@@ -137,19 +278,36 @@ export const ReorderableList = <T extends { id: string | number }>({
137
278
  onAction(item);
138
279
  };
139
280
 
281
+ // When list is empty, show a drop zone placeholder
282
+ const isEmpty = value.length === 0;
283
+
140
284
  const listBox = (
141
285
  <ListBox
142
286
  aria-label={ariaLabel}
143
287
  selectionMode="none"
144
- items={value}
145
288
  dragAndDropHooks={dragAndDropHooks}
146
289
  className={className}
147
290
  onAction={handleAction}
148
291
  >
149
- {(item) => (
150
- <ListBoxItem className="active:cursor-grabbing data-[dragging]:opacity-60">
292
+ {value.map((item) => (
293
+ <ListBoxItem
294
+ key={getKey(item)}
295
+ id={getKey(item)}
296
+ className="active:cursor-grabbing data-[dragging]:opacity-60 outline-none"
297
+ >
151
298
  {renderItem(item)}
152
299
  </ListBoxItem>
300
+ ))}
301
+ {/*
302
+ * When the list is empty, render an invisible placeholder item.
303
+ * This ensures the ListBox maintains minimum dimensions so users can:
304
+ * 1. Right-click to access the context menu and add items back
305
+ * 2. Drag items from another list into this empty list
306
+ */}
307
+ {isEmpty && (
308
+ <ListBoxItem id="__empty__" className="min-h-[40px] min-w-[40px]">
309
+ <span />
310
+ </ListBoxItem>
153
311
  )}
154
312
  </ListBox>
155
313
  );
@@ -164,19 +322,20 @@ export const ReorderableList = <T extends { id: string | number }>({
164
322
  <ContextMenuTrigger asChild={true}>{listBox}</ContextMenuTrigger>
165
323
  <ContextMenuContent>
166
324
  {availableItems.map((item) => {
167
- const isChecked = currentItemIds.has(item.id);
325
+ const key = getKey(item);
326
+ const isChecked = currentItemKeys.has(key);
168
327
  const isDisabled = isChecked && value.length <= minItems;
169
328
 
170
329
  return (
171
330
  <ContextMenuCheckboxItem
172
- key={item.id}
331
+ key={key}
173
332
  checked={isChecked}
174
333
  disabled={isDisabled}
175
334
  onCheckedChange={(checked) => {
176
335
  handleToggleItem(item, checked);
177
336
  }}
178
337
  >
179
- {getItemLabel(item)}
338
+ {getItemLabel ? getItemLabel(item) : key}
180
339
  </ContextMenuCheckboxItem>
181
340
  );
182
341
  })}
@@ -7,6 +7,7 @@ import { createTracebackInfoAtom } from "@/core/cells/cells";
7
7
  import { type CellId, HTMLCellId, SCRATCH_CELL_ID } from "@/core/cells/ids";
8
8
  import type { KeymapConfig } from "@/core/config/config-schema";
9
9
  import type { HotkeyProvider } from "@/core/hotkeys/hotkeys";
10
+ import { duplicateWithCtrlModifier } from "@/core/hotkeys/shortcuts";
10
11
  import { store } from "@/core/state/jotai";
11
12
  import { createObservable } from "@/core/state/observable";
12
13
  import { formatKeymapExtension } from "../extensions";
@@ -33,8 +34,9 @@ function cellKeymaps({
33
34
  }): Extension[] {
34
35
  const keybindings: KeyBinding[] = [];
35
36
 
37
+ // Run-related keybindings get Ctrl equivalents on macOS for Jupyter/Colab users
36
38
  keybindings.push(
37
- {
39
+ ...duplicateWithCtrlModifier({
38
40
  key: hotkeys.getHotkey("cell.run").key,
39
41
  preventDefault: true,
40
42
  stopPropagation: true,
@@ -43,8 +45,9 @@ function cellKeymaps({
43
45
  actions.onRun();
44
46
  return true;
45
47
  },
46
- },
48
+ }),
47
49
  {
50
+ // Shift-Enter has no Cmd, so no Ctrl equivalent needed
48
51
  key: hotkeys.getHotkey("cell.runAndNewBelow").key,
49
52
  preventDefault: true,
50
53
  stopPropagation: true,
@@ -59,7 +62,7 @@ function cellKeymaps({
59
62
  return true;
60
63
  },
61
64
  },
62
- {
65
+ ...duplicateWithCtrlModifier({
63
66
  key: hotkeys.getHotkey("cell.runAndNewAbove").key,
64
67
  preventDefault: true,
65
68
  stopPropagation: true,
@@ -73,7 +76,7 @@ function cellKeymaps({
73
76
  actions.moveToNextCell({ cellId, before: true });
74
77
  return true;
75
78
  },
76
- },
79
+ }),
77
80
  {
78
81
  key: hotkeys.getHotkey("cell.aiCompletion").key,
79
82
  preventDefault: true,
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import { parseShortcut } from "../shortcuts";
3
+ import { duplicateWithCtrlModifier, parseShortcut } from "../shortcuts";
4
4
 
5
5
  describe("parseShortcut", () => {
6
6
  it("should recognize single key shortcuts", () => {
@@ -21,10 +21,14 @@ describe("parseShortcut", () => {
21
21
  expect(shortcut(event)).toBe(true);
22
22
  });
23
23
 
24
- it("should recognize combined Cmd key shortcuts", () => {
24
+ it("should recognize combined Cmd key shortcuts with meta or ctrl", () => {
25
25
  const shortcut = parseShortcut("Cmd-a");
26
- const event = new KeyboardEvent("keydown", { key: "a", metaKey: true });
27
- expect(shortcut(event)).toBe(true);
26
+ // Cmd should accept both metaKey and ctrlKey (like Mod)
27
+ const metaEvent = new KeyboardEvent("keydown", { key: "a", metaKey: true });
28
+ expect(shortcut(metaEvent)).toBe(true);
29
+
30
+ const ctrlEvent = new KeyboardEvent("keydown", { key: "a", ctrlKey: true });
31
+ expect(shortcut(ctrlEvent)).toBe(true);
28
32
  });
29
33
 
30
34
  it("should recognize Arrow key shortcuts", () => {
@@ -164,3 +168,56 @@ describe("parseShortcut", () => {
164
168
  expect(parseShortcut("Ctrl+A")(event)).toBe(false);
165
169
  });
166
170
  });
171
+
172
+ describe("duplicateWithCtrlModifier", () => {
173
+ it("should duplicate Cmd binding with Ctrl variant on macOS", () => {
174
+ // Mock macOS platform
175
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
176
+
177
+ const binding = { key: "Cmd-Enter", run: () => true };
178
+ const result = duplicateWithCtrlModifier(binding);
179
+
180
+ expect(result).toHaveLength(2);
181
+ expect(result[0].key).toBe("Cmd-Enter");
182
+ expect(result[1].key).toBe("Ctrl-Enter");
183
+
184
+ vi.restoreAllMocks();
185
+ });
186
+
187
+ it("should not duplicate binding without Cmd", () => {
188
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
189
+
190
+ const binding = { key: "Shift-Enter", run: () => true };
191
+ const result = duplicateWithCtrlModifier(binding);
192
+
193
+ expect(result).toHaveLength(1);
194
+ expect(result[0].key).toBe("Shift-Enter");
195
+
196
+ vi.restoreAllMocks();
197
+ });
198
+
199
+ it("should not duplicate Cmd-Ctrl binding to avoid Ctrl-Ctrl", () => {
200
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
201
+
202
+ const binding = { key: "Cmd-Ctrl-Enter", run: () => true };
203
+ const result = duplicateWithCtrlModifier(binding);
204
+
205
+ // Should NOT create a Ctrl-Ctrl-Enter variant
206
+ expect(result).toHaveLength(1);
207
+ expect(result[0].key).toBe("Cmd-Ctrl-Enter");
208
+
209
+ vi.restoreAllMocks();
210
+ });
211
+
212
+ it("should not duplicate on non-macOS platforms", () => {
213
+ vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Win32");
214
+
215
+ const binding = { key: "Cmd-Enter", run: () => true };
216
+ const result = duplicateWithCtrlModifier(binding);
217
+
218
+ expect(result).toHaveLength(1);
219
+ expect(result[0].key).toBe("Cmd-Enter");
220
+
221
+ vi.restoreAllMocks();
222
+ });
223
+ });
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import type { KeyBinding } from "@codemirror/view";
3
4
  import { Logger } from "@/utils/Logger";
4
5
  import { NOT_SET } from "./hotkeys";
5
6
 
@@ -106,8 +107,8 @@ function areKeysPressed(keys: string[], e: IKeyboardEvent): boolean {
106
107
  function normalizeKey(key: string): string {
107
108
  const specialKeys: { [key: string]: string } = {
108
109
  control: "ctrl",
109
- command: "meta",
110
- cmd: "meta",
110
+ command: "mod",
111
+ cmd: "mod",
111
112
  option: "alt",
112
113
  return: "enter",
113
114
  };
@@ -144,3 +145,34 @@ export function resolvePlatform(): Platform {
144
145
  }
145
146
  return "linux";
146
147
  }
148
+
149
+ /**
150
+ * On macOS, duplicate a Cmd-based keybinding to also work with Ctrl.
151
+ * This allows users coming from Jupyter/Colab to use Ctrl-Enter to run cells.
152
+ *
153
+ * Returns an array with the original binding, plus a Ctrl variant on macOS.
154
+ * For use with CodeMirror keymap bindings.
155
+ *
156
+ * Design decision: User-defined Cmd shortcuts also get Ctrl equivalents.
157
+ * The edge case is if a user wants `Cmd+<x>` and `Ctrl+<x>` to trigger
158
+ * different actions, this isn't currently supported. Given the relatively
159
+ * small number of keymaps, we're keeping this simple. If it becomes an issue,
160
+ * we can refactor to resolve a special "Mod" key internally and require users
161
+ * to specify explicit single-key mappings.
162
+ *
163
+ * Note: If the binding already contains Ctrl (e.g., Cmd-Ctrl-Enter),
164
+ * no duplication is done to avoid producing invalid Ctrl-Ctrl-key combos.
165
+ */
166
+ export function duplicateWithCtrlModifier<T extends KeyBinding>(
167
+ binding: T,
168
+ ): T[] {
169
+ // Skip if not macOS, not a Cmd binding, or already has Ctrl
170
+ if (
171
+ !isPlatformMac() ||
172
+ !binding.key?.includes("Cmd") ||
173
+ binding.key.includes("Ctrl")
174
+ ) {
175
+ return [binding];
176
+ }
177
+ return [binding, { ...binding, key: binding.key.replaceAll("Cmd", "Ctrl") }];
178
+ }