@marimo-team/islands 0.23.17-dev0 → 0.23.17-dev3
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.
- package/dist/{common-CotPLtrU.js → common-D747zlkP.js} +4 -4
- package/dist/main.js +2 -2
- package/dist/{reveal-component-IoHipy82.js → reveal-component-CcSMtt1K.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +423 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
- package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +234 -0
- package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
- package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
- package/src/components/editor/actions/export-dialog/export-dialog.tsx +190 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
- package/src/components/editor/actions/export-dialog/format-notice.tsx +138 -0
- package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
- package/src/components/editor/actions/export-dialog/state.ts +321 -0
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +380 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
- package/src/components/editor/actions/useNotebookActions.tsx +72 -170
- package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
- package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/download.ts +3 -1
- package/src/utils/shell.ts +17 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { assertNever } from "@/utils/assertNever";
|
|
4
4
|
import { KnownQueryParams } from "@/core/constants";
|
|
5
|
+
import { shellQuote } from "@/utils/shell";
|
|
5
6
|
|
|
6
7
|
export type AgentTab = "claude" | "codex" | "opencode" | "prompt";
|
|
7
8
|
|
|
@@ -23,27 +24,6 @@ export function getMarimoCommand(): string {
|
|
|
23
24
|
return import.meta.env.DEV ? "uv run marimo" : "uvx marimo@latest";
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* POSIX-quote a value for safe embedding in a shell command. These commands are
|
|
28
|
-
* meant to be copied into a terminal, so a url/token containing shell
|
|
29
|
-
* metacharacters (`'`, `&`, `$(...)`, ...) must not break out of its argument.
|
|
30
|
-
*
|
|
31
|
-
* Mirrors Python's `shlex.quote` (used on the CLI side in
|
|
32
|
-
* `marimo/_cli/pair/commands.py`) so both sides produce identical commands:
|
|
33
|
-
* values that are already shell-safe are left as-is for readability, and
|
|
34
|
-
* anything else is single-quoted with embedded quotes escaped as `'"'"'`.
|
|
35
|
-
*/
|
|
36
|
-
export function shellQuote(value: string): string {
|
|
37
|
-
if (value === "") {
|
|
38
|
-
return "''";
|
|
39
|
-
}
|
|
40
|
-
// Same "safe" character set as CPython's shlex.quote (ASCII \w plus a few).
|
|
41
|
-
if (/^[\w@%+=:,./-]+$/.test(value)) {
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
27
|
/** Return the server file key from a page URL, preserving its decoded value. */
|
|
48
28
|
export function getFileFromURL(href: string): string | undefined {
|
|
49
29
|
const file = new URL(href).searchParams.get(KnownQueryParams.filePath);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
4
|
+
import type { RefObject } from "react";
|
|
4
5
|
import {
|
|
5
6
|
BookMarkedIcon,
|
|
6
7
|
CheckIcon,
|
|
@@ -51,7 +52,6 @@ import { GitHubIcon } from "@/components/icons/github";
|
|
|
51
52
|
import { MarimoPlusIcon } from "@/components/icons/marimo-icons";
|
|
52
53
|
import { YouTubeIcon } from "@/components/icons/youtube";
|
|
53
54
|
import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
54
|
-
import { renderShortcut } from "@/components/shortcuts/renderShortcut";
|
|
55
55
|
import { PairWithAgentModal } from "@/components/editor/actions/pair-with-agent-modal";
|
|
56
56
|
import { ShareStaticNotebookModal } from "@/components/static-html/share-modal";
|
|
57
57
|
import { toast } from "@/components/ui/use-toast";
|
|
@@ -66,30 +66,15 @@ import { disabledCellIds } from "@/core/cells/utils";
|
|
|
66
66
|
import { capabilitiesAtom } from "@/core/config/capabilities";
|
|
67
67
|
import { aiEnabledAtom, useResolvedMarimoConfig } from "@/core/config/config";
|
|
68
68
|
import { Constants } from "@/core/constants";
|
|
69
|
-
import {
|
|
70
|
-
updateCellOutputsWithScreenshots,
|
|
71
|
-
useEnrichCellOutputs,
|
|
72
|
-
} from "@/core/export/hooks";
|
|
73
69
|
import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
|
|
74
70
|
import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
|
|
75
71
|
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
76
72
|
import { useRequestClient } from "@/core/network/requests";
|
|
77
73
|
import { useFilename } from "@/core/saving/filename";
|
|
78
|
-
import { downloadAsHTML } from "@/core/static/download-html";
|
|
79
74
|
import { createShareableLink } from "@/core/wasm/share";
|
|
80
75
|
import { isWasm } from "@/core/wasm/utils";
|
|
81
76
|
import { copyToClipboard } from "@/utils/copy";
|
|
82
|
-
import {
|
|
83
|
-
ADD_PRINTING_CLASS,
|
|
84
|
-
downloadAsPDF,
|
|
85
|
-
downloadBlob,
|
|
86
|
-
downloadExportedFile,
|
|
87
|
-
downloadHTMLAsImage,
|
|
88
|
-
withLoadingToast,
|
|
89
|
-
} from "@/utils/download";
|
|
90
|
-
import { Filenames } from "@/utils/filenames";
|
|
91
77
|
import { Objects } from "@/utils/objects";
|
|
92
|
-
import type { ProgressState } from "@/utils/progress";
|
|
93
78
|
import { Strings } from "@/utils/strings";
|
|
94
79
|
import { newNotebookURL } from "@/utils/urls";
|
|
95
80
|
import { useRunAllCells } from "../cell/useRunCells";
|
|
@@ -100,7 +85,13 @@ import { keyboardShortcutsAtom } from "../controls/keyboard-shortcuts";
|
|
|
100
85
|
import { commandPaletteAtom } from "../controls/state";
|
|
101
86
|
import { displayLayoutName, getLayoutIcon } from "../renderers/layout-select";
|
|
102
87
|
import { LAYOUT_TYPES } from "../renderers/types";
|
|
103
|
-
import {
|
|
88
|
+
import { ExportDialog } from "./export-dialog/export-dialog";
|
|
89
|
+
import {
|
|
90
|
+
applyExportOptionOverrides,
|
|
91
|
+
exportOptionsAtom,
|
|
92
|
+
type ExportFormat,
|
|
93
|
+
type ExportOptionOverrides,
|
|
94
|
+
} from "./export-dialog/state";
|
|
104
95
|
import type { ActionButton } from "./types";
|
|
105
96
|
import { useCopyNotebook } from "./useCopyNotebook";
|
|
106
97
|
import { useRestartKernel } from "./useRestartKernel";
|
|
@@ -111,7 +102,11 @@ const NOOP_HANDLER = (event?: Event) => {
|
|
|
111
102
|
event?.stopPropagation();
|
|
112
103
|
};
|
|
113
104
|
|
|
114
|
-
export function useNotebookActions(
|
|
105
|
+
export function useNotebookActions({
|
|
106
|
+
exportDialogReturnFocusRef,
|
|
107
|
+
}: {
|
|
108
|
+
exportDialogReturnFocusRef?: RefObject<HTMLElement | null>;
|
|
109
|
+
} = {}) {
|
|
115
110
|
const filename = useFilename();
|
|
116
111
|
const { openModal, closeModal } = useImperativeModal();
|
|
117
112
|
const { toggleApplication } = useChromeActions();
|
|
@@ -138,15 +133,8 @@ export function useNotebookActions() {
|
|
|
138
133
|
const setSettingsDialogOpen = useSetAtom(settingDialogAtom);
|
|
139
134
|
const { handleClick: openSettings } = useOpenSettingsToTab();
|
|
140
135
|
const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
exportAsMarkdown,
|
|
144
|
-
exportAsScript,
|
|
145
|
-
readCode,
|
|
146
|
-
saveCellConfig,
|
|
147
|
-
updateCellOutputs,
|
|
148
|
-
} = useRequestClient();
|
|
149
|
-
const takeScreenshots = useEnrichCellOutputs();
|
|
136
|
+
const setExportOptions = useSetAtom(exportOptionsAtom);
|
|
137
|
+
const { readCode, saveCellConfig } = useRequestClient();
|
|
150
138
|
|
|
151
139
|
const hasDisabledCells = useAtomValue(hasDisabledCellsAtom);
|
|
152
140
|
const canUndoDeletes = useAtomValue(canUndoDeletesAtom);
|
|
@@ -158,10 +146,6 @@ export function useNotebookActions() {
|
|
|
158
146
|
const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
|
|
159
147
|
const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
|
|
160
148
|
const sharingMolabEnabled = resolvedConfig.sharing?.molab ?? true;
|
|
161
|
-
|
|
162
|
-
// Server-side PDF export is always available outside WASM.
|
|
163
|
-
// Browser print fallback is used in WASM.
|
|
164
|
-
const serverSidePdfEnabled = !isWasm();
|
|
165
149
|
const isSlidesLayout = selectedLayout === "slides";
|
|
166
150
|
|
|
167
151
|
const renderCheckboxElement = (checked: boolean) => (
|
|
@@ -170,82 +154,39 @@ export function useNotebookActions() {
|
|
|
170
154
|
</div>
|
|
171
155
|
);
|
|
172
156
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
157
|
+
const openExportDialog = (
|
|
158
|
+
initialFormat?: ExportFormat,
|
|
159
|
+
optionOverrides?: ExportOptionOverrides,
|
|
160
|
+
) => {
|
|
161
|
+
if (optionOverrides) {
|
|
162
|
+
setExportOptions((current) =>
|
|
163
|
+
applyExportOptionOverrides(current, optionOverrides),
|
|
164
|
+
);
|
|
176
165
|
}
|
|
177
|
-
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
166
|
+
openModal(
|
|
167
|
+
<ExportDialog
|
|
168
|
+
initialFormat={initialFormat}
|
|
169
|
+
onClose={closeModal}
|
|
170
|
+
returnFocusRef={exportDialogReturnFocusRef}
|
|
171
|
+
/>,
|
|
181
172
|
);
|
|
182
173
|
};
|
|
183
174
|
|
|
184
|
-
const downloadServerSidePDF = async ({
|
|
185
|
-
preset,
|
|
186
|
-
title,
|
|
187
|
-
}: {
|
|
188
|
-
preset: "document" | "slides";
|
|
189
|
-
title: string;
|
|
190
|
-
}) => {
|
|
191
|
-
if (!filename) {
|
|
192
|
-
toastNotebookMustBeNamed();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const runDownload = async (progress: ProgressState) => {
|
|
197
|
-
await runServerSidePDFDownload({
|
|
198
|
-
exportOptions: {
|
|
199
|
-
webpdf: false,
|
|
200
|
-
preset,
|
|
201
|
-
includeInputs: true,
|
|
202
|
-
includeOutputs: true,
|
|
203
|
-
},
|
|
204
|
-
captureOutputs: () =>
|
|
205
|
-
updateCellOutputsWithScreenshots({
|
|
206
|
-
takeScreenshots: () => takeScreenshots({ progress }),
|
|
207
|
-
updateCellOutputs,
|
|
208
|
-
}),
|
|
209
|
-
downloadPDF: downloadAsPDF,
|
|
210
|
-
});
|
|
211
|
-
};
|
|
212
|
-
await withLoadingToast(title, runDownload);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const handleDocumentPDF = async () => {
|
|
216
|
-
if (serverSidePdfEnabled) {
|
|
217
|
-
await downloadServerSidePDF({
|
|
218
|
-
preset: "document",
|
|
219
|
-
title: "Downloading Document PDF...",
|
|
220
|
-
});
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const beforeprint = new Event("export-beforeprint");
|
|
224
|
-
const afterprint = new Event("export-afterprint");
|
|
225
|
-
window.dispatchEvent(beforeprint);
|
|
226
|
-
setTimeout(() => window.print(), 0);
|
|
227
|
-
setTimeout(() => window.dispatchEvent(afterprint), 0);
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const handleDownloadAsIPYNB = async () => {
|
|
231
|
-
if (!filename) {
|
|
232
|
-
toastNotebookMustBeNamed();
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const runDownload = async (progress: ProgressState) => {
|
|
237
|
-
await updateCellOutputsWithScreenshots({
|
|
238
|
-
takeScreenshots: () => takeScreenshots({ progress }),
|
|
239
|
-
updateCellOutputs,
|
|
240
|
-
});
|
|
241
|
-
const exportedFile = await exportAsIPYNB({ download: false });
|
|
242
|
-
downloadExportedFile(exportedFile);
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
await withLoadingToast("Downloading IPYNB...", runDownload);
|
|
246
|
-
};
|
|
247
|
-
|
|
248
175
|
const actions: ActionButton[] = [
|
|
176
|
+
{
|
|
177
|
+
icon: <DownloadIcon size={14} strokeWidth={1.5} />,
|
|
178
|
+
label: "Export…",
|
|
179
|
+
additionalKeywords: [
|
|
180
|
+
"download",
|
|
181
|
+
"html",
|
|
182
|
+
"markdown",
|
|
183
|
+
"ipynb",
|
|
184
|
+
"pdf",
|
|
185
|
+
"script",
|
|
186
|
+
"png",
|
|
187
|
+
],
|
|
188
|
+
handle: () => openExportDialog(),
|
|
189
|
+
},
|
|
249
190
|
{
|
|
250
191
|
icon: <DownloadIcon size={14} strokeWidth={1.5} />,
|
|
251
192
|
label: "Download",
|
|
@@ -254,83 +195,44 @@ export function useNotebookActions() {
|
|
|
254
195
|
{
|
|
255
196
|
icon: <FolderDownIcon size={14} strokeWidth={1.5} />,
|
|
256
197
|
label: "Download as HTML",
|
|
257
|
-
handle:
|
|
258
|
-
|
|
259
|
-
toastNotebookMustBeNamed();
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
await downloadAsHTML({ includeCode: true });
|
|
263
|
-
},
|
|
198
|
+
handle: () =>
|
|
199
|
+
openExportDialog("html", { html: { includeCode: true } }),
|
|
264
200
|
},
|
|
265
201
|
{
|
|
266
202
|
icon: <FolderDownIcon size={14} strokeWidth={1.5} />,
|
|
267
203
|
label: "Download as HTML (exclude code)",
|
|
268
|
-
handle:
|
|
269
|
-
|
|
270
|
-
toastNotebookMustBeNamed();
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
await downloadAsHTML({ includeCode: false });
|
|
274
|
-
},
|
|
204
|
+
handle: () =>
|
|
205
|
+
openExportDialog("html", { html: { includeCode: false } }),
|
|
275
206
|
},
|
|
276
207
|
{
|
|
277
208
|
icon: (
|
|
278
209
|
<MarkdownIcon strokeWidth={1.5} style={{ width: 14, height: 14 }} />
|
|
279
210
|
),
|
|
280
211
|
label: "Download as Markdown",
|
|
281
|
-
handle:
|
|
282
|
-
const exportedFile = await exportAsMarkdown({ download: false });
|
|
283
|
-
downloadExportedFile(exportedFile);
|
|
284
|
-
},
|
|
212
|
+
handle: () => openExportDialog("markdown"),
|
|
285
213
|
},
|
|
286
214
|
{
|
|
287
215
|
icon: <NotebookIcon size={14} strokeWidth={1.5} />,
|
|
288
216
|
label: "Download as ipynb",
|
|
289
|
-
handle:
|
|
217
|
+
handle: () => openExportDialog("ipynb"),
|
|
290
218
|
},
|
|
291
219
|
{
|
|
292
220
|
icon: <CodeIcon size={14} strokeWidth={1.5} />,
|
|
293
221
|
label: "Download notebook source",
|
|
294
|
-
handle:
|
|
295
|
-
|
|
296
|
-
downloadBlob(
|
|
297
|
-
new Blob([code.contents], { type: "text/plain" }),
|
|
298
|
-
Filenames.toPY(document.title),
|
|
299
|
-
);
|
|
300
|
-
},
|
|
222
|
+
handle: () =>
|
|
223
|
+
openExportDialog("script", { script: { type: "source" } }),
|
|
301
224
|
},
|
|
302
225
|
{
|
|
303
226
|
icon: <CodeIcon size={14} strokeWidth={1.5} />,
|
|
304
227
|
label: "Download flat script",
|
|
305
|
-
handle:
|
|
306
|
-
|
|
307
|
-
downloadExportedFile(exportedFile);
|
|
308
|
-
},
|
|
228
|
+
handle: () =>
|
|
229
|
+
openExportDialog("script", { script: { type: "flat" } }),
|
|
309
230
|
},
|
|
310
231
|
{
|
|
311
232
|
divider: true,
|
|
312
233
|
icon: <ImageIcon size={14} strokeWidth={1.5} />,
|
|
313
234
|
label: "Download as PNG",
|
|
314
|
-
|
|
315
|
-
tooltip:
|
|
316
|
-
viewState.mode === "present" ? undefined : (
|
|
317
|
-
<span>
|
|
318
|
-
Only available in app view. <br />
|
|
319
|
-
Toggle with: {renderShortcut("global.hideCode", false)}
|
|
320
|
-
</span>
|
|
321
|
-
),
|
|
322
|
-
handle: async () => {
|
|
323
|
-
const app = document.getElementById("App");
|
|
324
|
-
if (!app) {
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
await downloadHTMLAsImage({
|
|
328
|
-
element: app,
|
|
329
|
-
filename: document.title,
|
|
330
|
-
// Add body.printing ONLY when converting the whole notebook to a screenshot
|
|
331
|
-
prepare: ADD_PRINTING_CLASS,
|
|
332
|
-
});
|
|
333
|
-
},
|
|
235
|
+
handle: () => openExportDialog("png"),
|
|
334
236
|
},
|
|
335
237
|
isSlidesLayout
|
|
336
238
|
? {
|
|
@@ -342,19 +244,24 @@ export function useNotebookActions() {
|
|
|
342
244
|
{
|
|
343
245
|
icon: <FileIcon size={14} strokeWidth={1.5} />,
|
|
344
246
|
label: "Document Layout",
|
|
345
|
-
handle:
|
|
247
|
+
handle: () =>
|
|
248
|
+
openExportDialog("pdf", {
|
|
249
|
+
pdf: { preset: "document" },
|
|
250
|
+
}),
|
|
346
251
|
},
|
|
347
252
|
{
|
|
348
253
|
icon: <FileIcon size={14} strokeWidth={1.5} />,
|
|
349
254
|
label: "Slides Layout",
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
255
|
+
hidden: isWasm(),
|
|
256
|
+
rightElement: (
|
|
257
|
+
<span className="ml-3 shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
|
|
258
|
+
Recommended
|
|
259
|
+
</span>
|
|
260
|
+
),
|
|
261
|
+
handle: () =>
|
|
262
|
+
openExportDialog("pdf", {
|
|
263
|
+
pdf: { preset: "slides" },
|
|
264
|
+
}),
|
|
358
265
|
},
|
|
359
266
|
],
|
|
360
267
|
}
|
|
@@ -362,7 +269,10 @@ export function useNotebookActions() {
|
|
|
362
269
|
divider: true,
|
|
363
270
|
icon: <FileIcon size={14} strokeWidth={1.5} />,
|
|
364
271
|
label: "Download as PDF",
|
|
365
|
-
handle:
|
|
272
|
+
handle: () =>
|
|
273
|
+
openExportDialog("pdf", {
|
|
274
|
+
pdf: { preset: "document" },
|
|
275
|
+
}),
|
|
366
276
|
},
|
|
367
277
|
],
|
|
368
278
|
},
|
|
@@ -740,11 +650,3 @@ export function useNotebookActions() {
|
|
|
740
650
|
return action;
|
|
741
651
|
});
|
|
742
652
|
}
|
|
743
|
-
|
|
744
|
-
function toastNotebookMustBeNamed() {
|
|
745
|
-
toast({
|
|
746
|
-
title: "Error",
|
|
747
|
-
description: "Notebooks must be named to be exported.",
|
|
748
|
-
variant: "danger",
|
|
749
|
-
});
|
|
750
|
-
}
|
|
@@ -87,7 +87,7 @@ export const AddConnectionDialogContent: React.FC<{
|
|
|
87
87
|
<span className="block">{codeSnippetHint}</span>
|
|
88
88
|
</DialogDescription>
|
|
89
89
|
</DialogHeader>
|
|
90
|
-
<AutoDiscoveredDataSources onSubmit={onClose} />
|
|
90
|
+
<AutoDiscoveredDataSources onSubmit={onClose} className="-mt-2" />
|
|
91
91
|
<Tabs
|
|
92
92
|
value={activeTab}
|
|
93
93
|
onValueChange={(v) => setActiveTab(v as ConnectionTab)}
|
|
@@ -5,11 +5,13 @@ import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
|
|
|
5
5
|
import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
|
|
6
6
|
import { useDataSourceDiscovery } from "@/hooks/useDataSourceDiscovery";
|
|
7
7
|
import { useInsertCode } from "./components";
|
|
8
|
+
import { cn } from "@/utils/cn";
|
|
8
9
|
|
|
9
10
|
export const QuickAddDataSources: React.FC<{
|
|
11
|
+
className?: string;
|
|
10
12
|
sources: DetectedDataSource[];
|
|
11
13
|
onAdd: (source: DetectedDataSource) => void;
|
|
12
|
-
}> = ({ sources, onAdd }) => {
|
|
14
|
+
}> = ({ className, sources, onAdd }) => {
|
|
13
15
|
if (sources.length === 0) {
|
|
14
16
|
return null;
|
|
15
17
|
}
|
|
@@ -17,12 +19,15 @@ export const QuickAddDataSources: React.FC<{
|
|
|
17
19
|
return (
|
|
18
20
|
<section
|
|
19
21
|
aria-labelledby="quick-add-data-sources-title"
|
|
20
|
-
className=
|
|
22
|
+
className={cn(
|
|
23
|
+
"rounded-full bg-[linear-gradient(135deg,var(--blue-2),var(--purple-3))] px-3 py-2",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
21
26
|
>
|
|
22
27
|
<div className="flex flex-wrap items-center gap-2">
|
|
23
28
|
<div className="mr-1 flex items-center gap-1.5">
|
|
24
|
-
<SparklesIcon className="h-3.5 w-3.5 text-
|
|
25
|
-
<h3 id="quick-add-data-sources-title" className="text-sm
|
|
29
|
+
<SparklesIcon className="h-3.5 w-3.5 text-(--blue-9)" />
|
|
30
|
+
<h3 id="quick-add-data-sources-title" className="text-sm">
|
|
26
31
|
Quick add
|
|
27
32
|
</h3>
|
|
28
33
|
</div>
|
|
@@ -36,10 +41,10 @@ export const QuickAddDataSources: React.FC<{
|
|
|
36
41
|
<button
|
|
37
42
|
type="button"
|
|
38
43
|
aria-label={`Add ${source.displayName} connection`}
|
|
39
|
-
className="inline-flex items-center gap-1 rounded-full border border-
|
|
44
|
+
className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs font-medium text-foreground/90 transition-colors hover:border-(--blue-7) focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
40
45
|
onClick={() => onAdd(source)}
|
|
41
46
|
>
|
|
42
|
-
<PlusIcon className="h-3 w-3" />
|
|
47
|
+
<PlusIcon className="h-3 w-3 text-muted-foreground" />
|
|
43
48
|
{source.displayName}
|
|
44
49
|
</button>
|
|
45
50
|
</Tooltip>
|
|
@@ -90,12 +95,14 @@ const DetectedDataSourceDetails: React.FC<{
|
|
|
90
95
|
|
|
91
96
|
export const AutoDiscoveredDataSources: React.FC<{
|
|
92
97
|
onSubmit: () => void;
|
|
93
|
-
|
|
98
|
+
className?: string;
|
|
99
|
+
}> = ({ onSubmit, className }) => {
|
|
94
100
|
const insertCode = useInsertCode();
|
|
95
101
|
const { data } = useDataSourceDiscovery();
|
|
96
102
|
|
|
97
103
|
return (
|
|
98
104
|
<QuickAddDataSources
|
|
105
|
+
className={className}
|
|
99
106
|
sources={data ?? []}
|
|
100
107
|
onAdd={(source) => {
|
|
101
108
|
insertCode(source.code);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { Provider } from "jotai";
|
|
5
|
+
import type React from "react";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
8
|
+
import { ModalProvider } from "@/components/modal/ImperativeModal";
|
|
9
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
10
|
+
import { layoutStateAtom } from "@/core/layout/layout";
|
|
11
|
+
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
|
|
12
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
13
|
+
import { filenameAtom } from "@/core/saving/file-state";
|
|
14
|
+
import { store } from "@/core/state/jotai";
|
|
15
|
+
import { isWasm } from "@/core/wasm/utils";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_EXPORT_OPTIONS,
|
|
18
|
+
exportOptionsAtom,
|
|
19
|
+
lastExportFormatAtom,
|
|
20
|
+
} from "../../actions/export-dialog/state";
|
|
21
|
+
import { NotebookMenuDropdown } from "../notebook-menu-dropdown";
|
|
22
|
+
|
|
23
|
+
vi.mock("@/core/wasm/utils", async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
|
|
25
|
+
return { ...actual, isWasm: vi.fn(() => false) };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function wrapper({ children }: { children: React.ReactNode }) {
|
|
29
|
+
return (
|
|
30
|
+
<Provider store={store}>
|
|
31
|
+
<TooltipProvider>
|
|
32
|
+
<ModalProvider>{children}</ModalProvider>
|
|
33
|
+
</TooltipProvider>
|
|
34
|
+
</Provider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function openNotebookMenu() {
|
|
39
|
+
fireEvent.pointerDown(screen.getByTestId("notebook-menu-dropdown"), {
|
|
40
|
+
button: 0,
|
|
41
|
+
ctrlKey: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function openDownloadMenu() {
|
|
46
|
+
openNotebookMenu();
|
|
47
|
+
fireEvent.click(await screen.findByRole("menuitem", { name: "Download" }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function selectDownload(name: string | RegExp) {
|
|
51
|
+
await openDownloadMenu();
|
|
52
|
+
fireEvent.click(await screen.findByRole("menuitem", { name }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("NotebookMenuDropdown", () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
localStorage.clear();
|
|
59
|
+
store.set(requestClientAtom, MockRequestClient.create());
|
|
60
|
+
store.set(filenameAtom, "/project/notebook.py");
|
|
61
|
+
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
|
|
62
|
+
store.set(kioskModeAtom, false);
|
|
63
|
+
store.set(layoutStateAtom, {
|
|
64
|
+
selectedLayout: "vertical",
|
|
65
|
+
layoutData: {},
|
|
66
|
+
});
|
|
67
|
+
store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
|
|
68
|
+
store.set(lastExportFormatAtom, "html");
|
|
69
|
+
vi.mocked(isWasm).mockReturnValue(false);
|
|
70
|
+
vi.stubGlobal("PointerEvent", MouseEvent);
|
|
71
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
72
|
+
matches: false,
|
|
73
|
+
addEventListener: vi.fn(),
|
|
74
|
+
removeEventListener: vi.fn(),
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
document.title = "";
|
|
80
|
+
vi.unstubAllGlobals();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns focus to the notebook menu when the dialog closes", async () => {
|
|
84
|
+
render(<NotebookMenuDropdown />, { wrapper });
|
|
85
|
+
const menuButton = screen.getByTestId("notebook-menu-dropdown");
|
|
86
|
+
|
|
87
|
+
openNotebookMenu();
|
|
88
|
+
fireEvent.click(
|
|
89
|
+
await screen.findByRole("menuitem", {
|
|
90
|
+
name: "Export…",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
fireEvent.click(await screen.findByRole("button", { name: "Close" }));
|
|
94
|
+
|
|
95
|
+
await waitFor(() => expect(menuButton).toHaveFocus());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("opens the HTML shortcut with code excluded", async () => {
|
|
99
|
+
render(<NotebookMenuDropdown />, { wrapper });
|
|
100
|
+
|
|
101
|
+
await selectDownload("Download as HTML (exclude code)");
|
|
102
|
+
|
|
103
|
+
expect(await screen.findByTestId("export-dialog")).toBeVisible();
|
|
104
|
+
expect(screen.getByTestId("export-format-html")).toHaveAttribute(
|
|
105
|
+
"aria-selected",
|
|
106
|
+
"true",
|
|
107
|
+
);
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByRole("switch", { name: "Include code" }),
|
|
110
|
+
).not.toBeChecked();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("opens the slides PDF shortcut with the slides layout", async () => {
|
|
114
|
+
store.set(layoutStateAtom, {
|
|
115
|
+
selectedLayout: "slides",
|
|
116
|
+
layoutData: {},
|
|
117
|
+
});
|
|
118
|
+
render(<NotebookMenuDropdown />, { wrapper });
|
|
119
|
+
|
|
120
|
+
await openDownloadMenu();
|
|
121
|
+
fireEvent.click(
|
|
122
|
+
await screen.findByRole("menuitem", {
|
|
123
|
+
name: "Download as PDF",
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
fireEvent.click(
|
|
127
|
+
await screen.findByRole("menuitem", {
|
|
128
|
+
name: /Slides Layout/,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(await screen.findByTestId("export-dialog")).toBeVisible();
|
|
133
|
+
expect(screen.getByTestId("export-format-pdf")).toHaveAttribute(
|
|
134
|
+
"aria-selected",
|
|
135
|
+
"true",
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByRole("radio", { name: "Slides" })).toBeChecked();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("hides the slides PDF shortcut in WebAssembly", async () => {
|
|
141
|
+
vi.mocked(isWasm).mockReturnValue(true);
|
|
142
|
+
store.set(layoutStateAtom, {
|
|
143
|
+
selectedLayout: "slides",
|
|
144
|
+
layoutData: {},
|
|
145
|
+
});
|
|
146
|
+
render(<NotebookMenuDropdown />, { wrapper });
|
|
147
|
+
|
|
148
|
+
await openDownloadMenu();
|
|
149
|
+
fireEvent.click(
|
|
150
|
+
await screen.findByRole("menuitem", {
|
|
151
|
+
name: "Download as PDF",
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
screen.getByRole("menuitem", { name: "Document Layout" }),
|
|
157
|
+
).toBeVisible();
|
|
158
|
+
expect(
|
|
159
|
+
screen.queryByRole("menuitem", { name: /Slides Layout/ }),
|
|
160
|
+
).not.toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("preselects each Python format from its download shortcut", async () => {
|
|
164
|
+
render(<NotebookMenuDropdown />, { wrapper });
|
|
165
|
+
|
|
166
|
+
await selectDownload("Download notebook source");
|
|
167
|
+
|
|
168
|
+
expect(await screen.findByTestId("export-dialog")).toBeVisible();
|
|
169
|
+
expect(screen.getByTestId("export-format-script")).toHaveAttribute(
|
|
170
|
+
"aria-selected",
|
|
171
|
+
"true",
|
|
172
|
+
);
|
|
173
|
+
expect(
|
|
174
|
+
screen.getByRole("radio", { name: "Notebook source" }),
|
|
175
|
+
).toBeChecked();
|
|
176
|
+
|
|
177
|
+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(screen.queryByTestId("export-dialog")).not.toBeInTheDocument(),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
await selectDownload("Download flat script");
|
|
183
|
+
|
|
184
|
+
expect(await screen.findByTestId("export-dialog")).toBeVisible();
|
|
185
|
+
expect(screen.getByRole("radio", { name: "Flat script" })).toBeChecked();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { MenuIcon } from "lucide-react";
|
|
4
|
-
import React from "react";
|
|
4
|
+
import React, { useRef } from "react";
|
|
5
5
|
import { useLocale } from "react-aria";
|
|
6
6
|
import { Button } from "@/components/editor/inputs/Inputs";
|
|
7
7
|
import {
|
|
@@ -33,7 +33,8 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
|
|
|
33
33
|
disabled = false,
|
|
34
34
|
tooltip = "Actions",
|
|
35
35
|
}) => {
|
|
36
|
-
const
|
|
36
|
+
const exportDialogReturnFocusRef = useRef<HTMLButtonElement>(null);
|
|
37
|
+
const actions = useNotebookActions({ exportDialogReturnFocusRef });
|
|
37
38
|
const { locale } = useLocale();
|
|
38
39
|
// Create tooltip content with keyboard shortcut decoration
|
|
39
40
|
const tooltipContent = (
|
|
@@ -50,6 +51,7 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
|
|
|
50
51
|
|
|
51
52
|
const button = (
|
|
52
53
|
<Button
|
|
54
|
+
ref={exportDialogReturnFocusRef}
|
|
53
55
|
aria-label="Config"
|
|
54
56
|
shape="circle"
|
|
55
57
|
size="small"
|