@marimo-team/islands 0.23.10-dev2 → 0.23.10-dev21

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 (52) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DdeG-Hi-.js} +23 -23
  2. package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-CiES2a2h.js} +2 -2
  3. package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
  4. package/dist/assets/worker-CC0Oul9k.js +73 -0
  5. package/dist/{chat-ui-ar37brtL.js → chat-ui-BTobdMRF.js} +61 -61
  6. package/dist/{code-visibility-B88v1No3.js → code-visibility-Cu6I0RUK.js} +1212 -1046
  7. package/dist/{copy-BuQpJEzp.js → copy-5jQ_kGE1.js} +32 -32
  8. package/dist/{esm-BfhQmZjp.js → esm-CCuYCd3R.js} +1 -1
  9. package/dist/{extends-BgdxCfYu.js → extends-CkydH1Q5.js} +1 -1
  10. package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-CRvL2R9l.js} +7 -7
  11. package/dist/{html-to-image-Cp8O1OWB.js → html-to-image-CjsdUYrb.js} +2258 -2238
  12. package/dist/{input-_2sjvfne.js → input-DVkbXbIX.js} +183 -181
  13. package/dist/main.js +1565 -1363
  14. package/dist/{process-output-CaUUWhh8.js → process-output-CI8a-CUx.js} +2 -2
  15. package/dist/{reveal-component-CfFoUPFg.js → reveal-component-EOadhR-6.js} +5 -5
  16. package/dist/{spec-B96zNUEA.js → spec-DMRQmLOc.js} +2 -2
  17. package/dist/{strings-Bu3vlb6W.js → strings-GCJA9n6d.js} +25 -24
  18. package/dist/style.css +1 -1
  19. package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-BRcO_TGJ.js} +1 -1
  20. package/package.json +3 -3
  21. package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
  22. package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
  23. package/src/components/data-table/hover-tooltip/content.ts +44 -0
  24. package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
  25. package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
  26. package/src/components/data-table/renderers.tsx +27 -43
  27. package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
  28. package/src/components/datasources/datasources.tsx +92 -3
  29. package/src/components/editor/cell/cell-context-menu.tsx +15 -2
  30. package/src/components/editor/documentation.css +16 -0
  31. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  32. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  33. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  34. package/src/components/slides/minimap.tsx +127 -10
  35. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  36. package/src/components/storage/storage-inspector.tsx +68 -48
  37. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  38. package/src/components/ui/use-toast.ts +33 -13
  39. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
  40. package/src/core/cells/__tests__/cell.test.ts +29 -2
  41. package/src/core/cells/cell.ts +5 -1
  42. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +37 -0
  43. package/src/core/codemirror/go-to-definition/commands.ts +17 -9
  44. package/src/core/codemirror/go-to-definition/utils.ts +1 -0
  45. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  46. package/src/core/datasets/data-source-connections.ts +2 -0
  47. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  48. package/src/core/network/requests-static.ts +14 -10
  49. package/src/core/wasm/worker/bootstrap.ts +12 -4
  50. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  51. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  52. package/dist/assets/worker-ip3AI_sN.js +0 -73
@@ -0,0 +1,183 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type {
5
+ Database,
6
+ DatabaseSchema,
7
+ DataTable,
8
+ } from "@/core/kernel/messages";
9
+ import { filterEmptyDatabases } from "../datasources";
10
+
11
+ function makeTable(name: string): DataTable {
12
+ return {
13
+ name,
14
+ columns: [],
15
+ source: "memory",
16
+ source_type: "local",
17
+ type: "table",
18
+ engine: null,
19
+ indexes: null,
20
+ num_columns: null,
21
+ num_rows: null,
22
+ variable_name: null,
23
+ primary_keys: null,
24
+ };
25
+ }
26
+
27
+ function makeSchema(opts: {
28
+ name: string;
29
+ tables: DataTable[];
30
+ tables_resolved?: boolean;
31
+ }): DatabaseSchema {
32
+ return {
33
+ name: opts.name,
34
+ tables: opts.tables,
35
+ tables_resolved: opts.tables_resolved ?? true,
36
+ };
37
+ }
38
+
39
+ function makeDatabase(
40
+ name: string,
41
+ schemas: DatabaseSchema[],
42
+ schemas_resolved = true,
43
+ ): Database {
44
+ return {
45
+ name,
46
+ dialect: "duckdb",
47
+ schemas,
48
+ schemas_resolved,
49
+ engine: null,
50
+ };
51
+ }
52
+
53
+ describe("filterEmptyDatabases", () => {
54
+ it("hides schemas whose tables are resolved and empty", () => {
55
+ const databases = [
56
+ makeDatabase("memory", [
57
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
58
+ makeSchema({ name: "empty_schema", tables: [] }),
59
+ ]),
60
+ ];
61
+
62
+ expect(filterEmptyDatabases(databases)).toEqual([
63
+ makeDatabase("memory", [
64
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
65
+ ]),
66
+ ]);
67
+ });
68
+
69
+ it("preserves databases whose schemas have not been resolved yet (lazy state)", () => {
70
+ const databases = [
71
+ makeDatabase("not_loaded_yet", [], /* schemas_resolved */ false),
72
+ ];
73
+
74
+ expect(filterEmptyDatabases(databases)).toEqual([
75
+ makeDatabase("not_loaded_yet", [], false),
76
+ ]);
77
+ });
78
+
79
+ it("hides databases that have been resolved as empty", () => {
80
+ const databases = [
81
+ makeDatabase("really_empty", [], /* schemas_resolved */ true),
82
+ makeDatabase("has_tables", [
83
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
84
+ ]),
85
+ ];
86
+
87
+ expect(filterEmptyDatabases(databases)).toEqual([
88
+ makeDatabase("has_tables", [
89
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
90
+ ]),
91
+ ]);
92
+ });
93
+
94
+ it("hides databases whose schemas all filtered to empty", () => {
95
+ const databases = [
96
+ makeDatabase("only_empty", [
97
+ makeSchema({ name: "a", tables: [] }),
98
+ makeSchema({ name: "b", tables: [] }),
99
+ ]),
100
+ makeDatabase("has_tables", [
101
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
102
+ ]),
103
+ ];
104
+
105
+ expect(filterEmptyDatabases(databases)).toEqual([
106
+ makeDatabase("has_tables", [
107
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
108
+ ]),
109
+ ]);
110
+ });
111
+
112
+ it("treats missing schemas_resolved as resolved (backward compatible)", () => {
113
+ const databases = [
114
+ { name: "memory", dialect: "duckdb", schemas: [], engine: null },
115
+ ] as Database[];
116
+
117
+ expect(filterEmptyDatabases(databases)).toEqual([]);
118
+ });
119
+
120
+ it("preserves schemas whose tables have not been resolved yet", () => {
121
+ const databases = [
122
+ makeDatabase("snowflake_db", [
123
+ // include_tables=False was used; the schema is not actually empty,
124
+ // tables will be fetched lazily on expand.
125
+ makeSchema({ name: "public", tables: [], tables_resolved: false }),
126
+ makeSchema({ name: "audit", tables: [], tables_resolved: false }),
127
+ makeSchema({
128
+ name: "really_empty",
129
+ tables: [],
130
+ tables_resolved: true,
131
+ }),
132
+ ]),
133
+ ];
134
+
135
+ expect(filterEmptyDatabases(databases)).toEqual([
136
+ makeDatabase("snowflake_db", [
137
+ makeSchema({ name: "public", tables: [], tables_resolved: false }),
138
+ makeSchema({ name: "audit", tables: [], tables_resolved: false }),
139
+ ]),
140
+ ]);
141
+ });
142
+
143
+ it("treats missing tables_resolved as resolved (backward compatible)", () => {
144
+ // Older payloads predating the new flag may omit it; default semantics
145
+ // treat the schema as resolved/authoritative.
146
+ const databases = [
147
+ makeDatabase("memory", [
148
+ { name: "main", tables: [makeTable("t1")] },
149
+ { name: "empty_schema", tables: [] },
150
+ ] as DatabaseSchema[]),
151
+ ];
152
+
153
+ expect(filterEmptyDatabases(databases)).toEqual([
154
+ makeDatabase("memory", [
155
+ { name: "main", tables: [makeTable("t1")] },
156
+ ] as DatabaseSchema[]),
157
+ ]);
158
+ });
159
+
160
+ it("returns the same reference when nothing was filtered", () => {
161
+ const databases = [
162
+ makeDatabase("memory", [
163
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
164
+ ]),
165
+ ];
166
+
167
+ expect(filterEmptyDatabases(databases)).toBe(databases);
168
+ });
169
+
170
+ it("does not mutate the input", () => {
171
+ const databases = [
172
+ makeDatabase("memory", [
173
+ makeSchema({ name: "main", tables: [makeTable("t1")] }),
174
+ makeSchema({ name: "empty_schema", tables: [] }),
175
+ ]),
176
+ ];
177
+ const snapshot = JSON.parse(JSON.stringify(databases));
178
+
179
+ filterEmptyDatabases(databases);
180
+
181
+ expect(databases).toEqual(snapshot);
182
+ });
183
+ });
@@ -1,13 +1,17 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { CommandList } from "cmdk";
4
- import { atom, useAtomValue, useSetAtom } from "jotai";
4
+ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
5
+ import { atomWithStorage } from "jotai/utils";
5
6
  import { PlusIcon, PlusSquareIcon, XIcon } from "lucide-react";
6
7
  import React from "react";
7
8
  import { dbDisplayName } from "@/components/databases/display";
8
9
  import { EngineVariable } from "@/components/databases/engine-variable";
9
10
  import { DatabaseLogo } from "@/components/databases/icon";
10
- import { RefreshIconButton } from "@/components/editor/file-tree/tree-actions";
11
+ import {
12
+ RefreshIconButton,
13
+ VisibilityToggleButton,
14
+ } from "@/components/editor/file-tree/tree-actions";
11
15
  import { CopyClipboardIcon } from "@/components/icons/copy-icon";
12
16
  import { Button } from "@/components/ui/button";
13
17
  import { Command, CommandInput, CommandItem } from "@/components/ui/command";
@@ -52,6 +56,7 @@ import { sortBy } from "@/utils/arrays";
52
56
  import { logNever } from "@/utils/assertNever";
53
57
  import { cn } from "@/utils/cn";
54
58
  import { Events } from "@/utils/events";
59
+ import { jotaiJsonStorage } from "@/utils/storage/jotai";
55
60
  import {
56
61
  DatabaseIcon,
57
62
  SchemaIcon,
@@ -116,6 +121,63 @@ const sortedTablesAtom = atom((get) => {
116
121
  });
117
122
  });
118
123
 
124
+ /**
125
+ * Whether to hide empty schemas and databases (those with no tables) in the
126
+ * datasources panel.
127
+ */
128
+ export const hideEmptyDatasourcesAtom = atomWithStorage<boolean>(
129
+ "marimo:datasources:hideEmpty",
130
+ false,
131
+ jotaiJsonStorage,
132
+ { getOnInit: true },
133
+ );
134
+
135
+ function isKnownEmptySchema(schema: DatabaseSchema): boolean {
136
+ return schema.tables_resolved !== false && schema.tables.length === 0;
137
+ }
138
+
139
+ /**
140
+ * Apply the "hide empty" filter to a connection's databases.
141
+ *
142
+ * - Schemas with confirmed-empty table lists are hidden.
143
+ * - Databases are hidden when either (a) their schemas have been enumerated
144
+ * and the list is empty, or (b) every schema in them was hidden by the
145
+ * schema-level filter.
146
+ * - Databases / schemas whose contents haven't been resolved yet (deferred
147
+ * discovery — `schemas_resolved === false` or `tables_resolved === false`)
148
+ * are preserved so the user can expand them to trigger a fetch.
149
+ */
150
+ export function filterEmptyDatabases(databases: Database[]): Database[] {
151
+ let changed = false;
152
+ const result: Database[] = [];
153
+ for (const database of databases) {
154
+ // Known-empty database: schema list was enumerated and is empty.
155
+ if (database.schemas_resolved !== false && database.schemas.length === 0) {
156
+ changed = true;
157
+ continue;
158
+ }
159
+ // Deferred schema discovery — keep so the user can expand and load.
160
+ if (database.schemas.length === 0) {
161
+ result.push(database);
162
+ continue;
163
+ }
164
+ const visibleSchemas = database.schemas.filter(
165
+ (schema) => !isKnownEmptySchema(schema),
166
+ );
167
+ if (visibleSchemas.length === 0) {
168
+ changed = true;
169
+ continue;
170
+ }
171
+ if (visibleSchemas.length === database.schemas.length) {
172
+ result.push(database);
173
+ continue;
174
+ }
175
+ changed = true;
176
+ result.push({ ...database, schemas: visibleSchemas });
177
+ }
178
+ return changed ? result : databases;
179
+ }
180
+
119
181
  /**
120
182
  * This atom is used to get the data connections that are available to the user.
121
183
  * It filters out the internal engines if it has no databases or if it has only the in-memory database and no schemas.
@@ -152,10 +214,27 @@ export const connectionsAtom = atom((get) => {
152
214
 
153
215
  export const DataSources: React.FC = () => {
154
216
  const [searchValue, setSearchValue] = React.useState<string>("");
217
+ const [hideEmpty, setHideEmpty] = useAtom(hideEmptyDatasourcesAtom);
155
218
 
156
219
  const closeAllColumns = useSetAtom(closeAllColumnsAtom);
157
220
  const tables = useAtomValue(sortedTablesAtom);
158
- const dataConnections = useAtomValue(connectionsAtom);
221
+ const rawConnections = useAtomValue(connectionsAtom);
222
+
223
+ const dataConnections = React.useMemo(() => {
224
+ if (!hideEmpty) {
225
+ return rawConnections;
226
+ }
227
+ let changed = false;
228
+ const filtered = rawConnections.map((connection) => {
229
+ const databases = filterEmptyDatabases(connection.databases);
230
+ if (databases === connection.databases) {
231
+ return connection;
232
+ }
233
+ changed = true;
234
+ return { ...connection, databases };
235
+ });
236
+ return changed ? filtered : rawConnections;
237
+ }, [rawConnections, hideEmpty]);
159
238
 
160
239
  if (tables.length === 0 && dataConnections.length === 0) {
161
240
  return (
@@ -204,6 +283,16 @@ export const DataSources: React.FC = () => {
204
283
  </button>
205
284
  )}
206
285
 
286
+ <VisibilityToggleButton
287
+ data-testid="datasources-hide-empty-button"
288
+ isVisible={!hideEmpty}
289
+ onToggle={() => setHideEmpty(!hideEmpty)}
290
+ showTooltip="Show empty schemas and databases"
291
+ hideTooltip="Hide empty schemas and databases"
292
+ size="sm"
293
+ className="px-2 rounded-none focus-visible:outline-hidden"
294
+ />
295
+
207
296
  <AddConnectionDialog>
208
297
  <Button
209
298
  variant="ghost"
@@ -61,6 +61,7 @@ export const CellActionsContextMenu = ({
61
61
  });
62
62
  const [imageRightClicked, setImageRightClicked] =
63
63
  React.useState<HTMLImageElement>();
64
+ const suppressCloseAutoFocus = React.useRef(false);
64
65
 
65
66
  const DEFAULT_CONTEXT_MENU_ITEMS: ActionButton[] = [
66
67
  {
@@ -166,7 +167,10 @@ export const CellActionsContextMenu = ({
166
167
  handle: () => {
167
168
  const editorView = getEditorView();
168
169
  if (editorView) {
169
- goToDefinitionAtCursorPosition(editorView);
170
+ // Only suppress focus restoration when we actually navigated;
171
+ // otherwise let Radix return focus to the trigger cell.
172
+ suppressCloseAutoFocus.current =
173
+ goToDefinitionAtCursorPosition(editorView);
170
174
  }
171
175
  },
172
176
  },
@@ -194,7 +198,16 @@ export const CellActionsContextMenu = ({
194
198
  >
195
199
  {children}
196
200
  </ContextMenuTrigger>
197
- <ContextMenuContent className="w-[300px]" scrollable={true}>
201
+ <ContextMenuContent
202
+ className="w-[300px]"
203
+ scrollable={true}
204
+ onCloseAutoFocus={(evt) => {
205
+ if (suppressCloseAutoFocus.current) {
206
+ evt.preventDefault();
207
+ suppressCloseAutoFocus.current = false;
208
+ }
209
+ }}
210
+ >
198
211
  {visibleActions.map((group, i) => (
199
212
  <Fragment key={i}>
200
213
  {group.map((action) => {
@@ -51,6 +51,22 @@
51
51
  }
52
52
  }
53
53
 
54
+ a {
55
+ cursor: pointer;
56
+ text-decoration: inherit;
57
+
58
+ @apply text-link;
59
+ }
60
+
61
+ a:hover,
62
+ a:active {
63
+ text-decoration: underline;
64
+ }
65
+
66
+ a:visited {
67
+ @apply text-link-visited;
68
+ }
69
+
54
70
  code {
55
71
  @apply border px-1 rounded font-mono bg-[var(--slate-2)] text-sm mb-4 mt-2;
56
72
  }
@@ -9,8 +9,6 @@ import {
9
9
  CopyMinusIcon,
10
10
  DownloadIcon,
11
11
  ExternalLinkIcon,
12
- EyeIcon,
13
- EyeOffIcon,
14
12
  FilePlus2Icon,
15
13
  FolderPlusIcon,
16
14
  ListTreeIcon,
@@ -43,6 +41,7 @@ import {
43
41
  MENU_ITEM_ICON_CLASS,
44
42
  RefreshIconButton,
45
43
  TreeChevron,
44
+ VisibilityToggleButton,
46
45
  } from "@/components/editor/file-tree/tree-actions";
47
46
  import { MarimoIcon, MarimoPlusIcon } from "@/components/icons/marimo-icons";
48
47
  import { Spinner } from "@/components/icons/spinner";
@@ -338,22 +337,13 @@ const Toolbar = ({
338
337
  data-testid="file-explorer-refresh-button"
339
338
  onClick={onRefresh}
340
339
  />
341
- <Tooltip
342
- content={showHiddenFiles ? "Hide hidden files" : "Show hidden files"}
343
- >
344
- <Button
345
- data-testid="file-explorer-hidden-files-button"
346
- onClick={onHidden}
347
- variant="text"
348
- size="xs"
349
- >
350
- {showHiddenFiles ? (
351
- <EyeIcon size={16} className="text-primary" />
352
- ) : (
353
- <EyeOffIcon size={16} />
354
- )}
355
- </Button>
356
- </Tooltip>
340
+ <VisibilityToggleButton
341
+ data-testid="file-explorer-hidden-files-button"
342
+ isVisible={showHiddenFiles}
343
+ onToggle={onHidden}
344
+ showTooltip="Show hidden files"
345
+ hideTooltip="Hide hidden files"
346
+ />
357
347
  <Tooltip content="Collapse all folders">
358
348
  <Button
359
349
  data-testid="file-explorer-collapse-button"
@@ -2,11 +2,13 @@
2
2
 
3
3
  import {
4
4
  ChevronRightIcon,
5
+ EyeIcon,
6
+ EyeOffIcon,
5
7
  MoreVerticalIcon,
6
8
  RefreshCwIcon,
7
9
  } from "lucide-react";
8
10
  import React, { useCallback, useState } from "react";
9
- import { Button } from "@/components/ui/button";
11
+ import { Button, type ButtonProps } from "@/components/ui/button";
10
12
  import { Tooltip } from "@/components/ui/tooltip";
11
13
  import { cn } from "@/utils/cn";
12
14
 
@@ -73,6 +75,49 @@ export const RefreshIconButton: React.FC<{
73
75
  return <Tooltip content={tooltip}>{button}</Tooltip>;
74
76
  };
75
77
 
78
+ /**
79
+ * Toggle button that switches between an eye (visible) and crossed-out eye
80
+ * (hidden) icon. Used to show/hide optional items in toolbars, e.g. hidden
81
+ * files in the file explorer or empty schemas in the data sources panel.
82
+ */
83
+ export const VisibilityToggleButton: React.FC<{
84
+ /** Whether the optional items are currently visible. */
85
+ isVisible: boolean;
86
+ onToggle: () => void;
87
+ showTooltip: string;
88
+ hideTooltip: string;
89
+ size?: ButtonProps["size"];
90
+ className?: string;
91
+ iconClassName?: string;
92
+ "data-testid"?: string;
93
+ }> = ({
94
+ isVisible,
95
+ onToggle,
96
+ showTooltip,
97
+ hideTooltip,
98
+ size = "xs",
99
+ className,
100
+ iconClassName,
101
+ "data-testid": dataTestId,
102
+ }) => {
103
+ const Icon = isVisible ? EyeIcon : EyeOffIcon;
104
+ return (
105
+ <Tooltip content={isVisible ? hideTooltip : showTooltip}>
106
+ <Button
107
+ data-testid={dataTestId}
108
+ variant="text"
109
+ size={size}
110
+ className={className}
111
+ onClick={onToggle}
112
+ >
113
+ <Icon
114
+ className={cn("h-4 w-4", isVisible && "text-primary", iconClassName)}
115
+ />
116
+ </Button>
117
+ </Tooltip>
118
+ );
119
+ };
120
+
76
121
  /**
77
122
  * Three-dot menu trigger that fades in on row hover.
78
123
  * Must be inside a `group` container.
@@ -0,0 +1,166 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { cellId } from "@/__tests__/branded";
6
+ import { TooltipProvider } from "@/components/ui/tooltip";
7
+ import type { CellId } from "@/core/cells/ids";
8
+ import type { CellData, CellRuntimeState } from "@/core/cells/types";
9
+ import { MultiColumn } from "@/utils/id-tree";
10
+ import { SlidesMinimap } from "../minimap";
11
+
12
+ const A = cellId("a");
13
+ const B = cellId("b");
14
+
15
+ // Spies shared with the hoisted module mocks below.
16
+ const { createNewCell, deleteCell, moveCellToIndex } = vi.hoisted(() => ({
17
+ createNewCell: vi.fn(),
18
+ deleteCell: vi.fn(),
19
+ moveCellToIndex: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@/core/cells/cells", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("@/core/cells/cells")>();
24
+ return {
25
+ ...actual,
26
+ useCellActions: () => ({ moveCellToIndex, createNewCell }),
27
+ useCellIds: () => MultiColumn.from([[A, B]]),
28
+ };
29
+ });
30
+
31
+ vi.mock("@/components/editor/cell/useDeleteCell", () => ({
32
+ useDeleteCellCallback: () => deleteCell,
33
+ }));
34
+
35
+ beforeAll(() => {
36
+ // jsdom doesn't implement these; radix menus poke at them.
37
+ global.HTMLElement.prototype.scrollIntoView = () => {
38
+ /* noop */
39
+ };
40
+ if (!global.HTMLElement.prototype.hasPointerCapture) {
41
+ global.HTMLElement.prototype.hasPointerCapture = () => false;
42
+ }
43
+ if (!global.HTMLElement.prototype.releasePointerCapture) {
44
+ global.HTMLElement.prototype.releasePointerCapture = () => {
45
+ /* noop */
46
+ };
47
+ }
48
+ global.IntersectionObserver ??= class {
49
+ observe() {
50
+ /* noop */
51
+ }
52
+ unobserve() {
53
+ /* noop */
54
+ }
55
+ disconnect() {
56
+ /* noop */
57
+ }
58
+ takeRecords() {
59
+ return [];
60
+ }
61
+ root = null;
62
+ rootMargin = "";
63
+ thresholds = [];
64
+ } as unknown as typeof IntersectionObserver;
65
+ });
66
+
67
+ // The minimap only reads `id`/`code`/`status`/`output`, so a minimal stub is
68
+ // enough; the cast is confined to this helper.
69
+ function makeCell(id: CellId): CellRuntimeState & CellData {
70
+ return {
71
+ id,
72
+ code: `print("${id}")`,
73
+ output: null,
74
+ status: "idle",
75
+ } as unknown as CellRuntimeState & CellData;
76
+ }
77
+
78
+ function renderMinimap() {
79
+ const onSlideClick = vi.fn();
80
+ const utils = render(
81
+ <TooltipProvider>
82
+ <SlidesMinimap
83
+ cells={[makeCell(A), makeCell(B)]}
84
+ thumbnailWidth={200}
85
+ canReorder={false}
86
+ activeCellId={null}
87
+ onSlideClick={onSlideClick}
88
+ />
89
+ </TooltipProvider>,
90
+ );
91
+ return { ...utils, onSlideClick };
92
+ }
93
+
94
+ const EMPTY_CELL = { code: "", autoFocus: false } as const;
95
+
96
+ describe("SlidesMinimap insert lines", () => {
97
+ it("inserts a blank cell above the first row and below any row", () => {
98
+ renderMinimap();
99
+ // First row exposes both an above and a below line; later rows only below.
100
+ // DOM order: [A-above, A-below, B-below].
101
+ const inserts = screen.getAllByTestId("minimap-insert-cell");
102
+ expect(inserts).toHaveLength(3);
103
+
104
+ fireEvent.click(inserts[0]);
105
+ expect(createNewCell).toHaveBeenLastCalledWith({
106
+ cellId: A,
107
+ before: true,
108
+ ...EMPTY_CELL,
109
+ });
110
+
111
+ fireEvent.click(inserts[1]);
112
+ expect(createNewCell).toHaveBeenLastCalledWith({
113
+ cellId: A,
114
+ before: false,
115
+ ...EMPTY_CELL,
116
+ });
117
+
118
+ fireEvent.click(inserts[2]);
119
+ expect(createNewCell).toHaveBeenLastCalledWith({
120
+ cellId: B,
121
+ before: false,
122
+ ...EMPTY_CELL,
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("SlidesMinimap context menu", () => {
128
+ const openRowMenu = (container: HTMLElement, id: CellId) => {
129
+ const row = container.querySelector<HTMLElement>(`[data-cell-id="${id}"]`);
130
+ expect(row).not.toBeNull();
131
+ fireEvent.contextMenu(row!);
132
+ };
133
+
134
+ it('"Add cell" inserts a blank cell below the row', () => {
135
+ const { container } = renderMinimap();
136
+ openRowMenu(container, A);
137
+ fireEvent.click(screen.getByText("Add cell"));
138
+ expect(createNewCell).toHaveBeenCalledWith({
139
+ cellId: A,
140
+ before: false,
141
+ ...EMPTY_CELL,
142
+ });
143
+ });
144
+
145
+ it('"Delete cell" deletes the row\'s cell', () => {
146
+ const { container } = renderMinimap();
147
+ openRowMenu(container, B);
148
+ fireEvent.click(screen.getByText("Delete cell"));
149
+ expect(deleteCell).toHaveBeenCalledWith({ cellId: B });
150
+ });
151
+ });
152
+
153
+ describe("SlidesMinimap keyboard activation", () => {
154
+ it("activates the row on Enter but ignores Space (reserved by reveal.js)", () => {
155
+ const { container, onSlideClick } = renderMinimap();
156
+ const row = container.querySelector<HTMLElement>(`[data-cell-id="${A}"]`);
157
+ expect(row).not.toBeNull();
158
+
159
+ fireEvent.keyDown(row!, { key: "Enter" });
160
+ expect(onSlideClick).toHaveBeenCalledWith(0);
161
+
162
+ onSlideClick.mockClear();
163
+ fireEvent.keyDown(row!, { key: " " });
164
+ expect(onSlideClick).not.toHaveBeenCalled();
165
+ });
166
+ });