@marimo-team/islands 0.19.5-dev4 → 0.19.5-dev43
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/{ConnectedDataExplorerComponent-D5KcOOzu.js → ConnectedDataExplorerComponent-DjQ_E5BA.js} +3 -3
- package/dist/assets/__vite-browser-external-DRa9CT_O.js +1 -0
- package/dist/assets/{worker-BR7KVExK.js → worker-SqntmiwV.js} +2 -2
- package/dist/{glide-data-editor-DsVDCmV2.js → glide-data-editor-zEomQJ3U.js} +2 -2
- package/dist/main.js +290 -244
- package/dist/{mermaid-DZjjc-kI.js → mermaid-D7wtYc6C.js} +2 -2
- package/dist/{spec-B1PGDiGh.js → spec-Cif4tBMJ.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{types-CbQF8CBX.js → types-BQOP2pRy.js} +1 -1
- package/dist/{useAsyncData-TLXJC7yx.js → useAsyncData-kqbhbSuf.js} +1 -1
- package/dist/{useDeepCompareMemoize-DVnEG7jx.js → useDeepCompareMemoize-B2QEm3jo.js} +1 -1
- package/dist/{useTheme-BllQjRdW.js → useTheme-CVr6Gb_R.js} +4 -1
- package/dist/{vega-component-B2QrGnW8.js → vega-component-DAeU1_cV.js} +3 -3
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
- package/src/components/ai/ai-utils.ts +101 -0
- package/src/components/app-config/ai-config.tsx +56 -16
- package/src/components/app-config/user-config-form.tsx +63 -1
- package/src/components/chat/chat-panel.tsx +4 -4
- package/src/components/data-table/cell-utils.ts +10 -0
- package/src/components/editor/Output.tsx +21 -14
- package/src/components/editor/actions/useCellActionButton.tsx +2 -2
- package/src/components/editor/actions/useNotebookActions.tsx +61 -21
- package/src/components/editor/cell/cell-actions.tsx +6 -1
- package/src/components/editor/controls/Controls.tsx +1 -8
- package/src/components/editor/file-tree/file-explorer.tsx +4 -2
- package/src/components/editor/file-tree/file-viewer.tsx +9 -6
- package/src/components/editor/navigation/navigation.ts +39 -1
- package/src/components/editor/renderMimeIcon.tsx +2 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
- package/src/core/ai/model-registry.ts +21 -3
- package/src/core/codemirror/language/panel/panel.tsx +3 -0
- package/src/core/codemirror/language/panel/sql.tsx +6 -2
- package/src/core/config/config-schema.ts +5 -1
- package/src/core/config/config.ts +4 -0
- package/src/core/config/feature-flag.tsx +2 -0
- package/src/core/export/__tests__/hooks.test.ts +120 -1
- package/src/core/export/hooks.ts +48 -18
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/lsp/__tests__/transport.test.ts +149 -0
- package/src/core/lsp/transport.ts +48 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +9 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +2 -0
- package/src/core/wasm/bridge.ts +1 -0
- package/src/css/app/Cell.css +0 -2
- package/src/plugins/layout/TexPlugin.tsx +7 -5
- package/src/utils/__tests__/download.test.tsx +492 -0
- package/src/utils/download.ts +161 -6
- package/src/utils/filenames.ts +3 -0
- package/dist/assets/__vite-browser-external-CgHmDpAZ.js +0 -1
- package/src/components/export/export-output-button.tsx +0 -14
|
@@ -8,6 +8,7 @@ import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
|
|
|
8
8
|
import { normalizeName } from "@/core/cells/names";
|
|
9
9
|
import { type ConnectionName, DUCKDB_ENGINE } from "@/core/datasets/engines";
|
|
10
10
|
import { useAutoGrowInputProps } from "@/hooks/useAutoGrowInputProps";
|
|
11
|
+
import { cellIdState } from "../../cells/state";
|
|
11
12
|
import { formatSQL } from "../../format";
|
|
12
13
|
import { languageAdapterState } from "../extension";
|
|
13
14
|
import { MarkdownLanguageAdapter } from "../languages/markdown";
|
|
@@ -31,6 +32,7 @@ export const LanguagePanelComponent: React.FC<{
|
|
|
31
32
|
}> = ({ view }) => {
|
|
32
33
|
const { spanProps, inputProps } = useAutoGrowInputProps({ minWidth: 50 });
|
|
33
34
|
const languageAdapter = view.state.field(languageAdapterState);
|
|
35
|
+
const cellId = view.state.facet(cellIdState);
|
|
34
36
|
|
|
35
37
|
let actions: React.ReactNode = <div />;
|
|
36
38
|
let showDivider = false;
|
|
@@ -93,6 +95,7 @@ export const LanguagePanelComponent: React.FC<{
|
|
|
93
95
|
<SQLEngineSelect
|
|
94
96
|
selectedEngine={metadata.engine}
|
|
95
97
|
onChange={switchEngine}
|
|
98
|
+
cellId={cellId}
|
|
96
99
|
/>
|
|
97
100
|
<div className="flex items-center gap-2 ml-auto">
|
|
98
101
|
{metadata.engine === DUCKDB_ENGINE && <SQLModeSelect />}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DatabaseBackup,
|
|
9
9
|
SearchCheck,
|
|
10
10
|
} from "lucide-react";
|
|
11
|
+
import { getCellForDomProps } from "@/components/data-table/cell-utils";
|
|
11
12
|
import { transformDisplayName } from "@/components/databases/display";
|
|
12
13
|
import { DatabaseLogo } from "@/components/databases/icon";
|
|
13
14
|
import { Button } from "@/components/ui/button";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
SelectValue,
|
|
23
24
|
} from "@/components/ui/select";
|
|
24
25
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
26
|
+
import type { CellId } from "@/core/cells/ids";
|
|
25
27
|
import {
|
|
26
28
|
dataConnectionsMapAtom,
|
|
27
29
|
setLatestEngineSelected,
|
|
@@ -38,11 +40,13 @@ import { type SQLMode, useSQLMode } from "../languages/sql/sql-mode";
|
|
|
38
40
|
interface SelectProps {
|
|
39
41
|
selectedEngine: ConnectionName;
|
|
40
42
|
onChange: (engine: ConnectionName) => void;
|
|
43
|
+
cellId: CellId;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export const SQLEngineSelect: React.FC<SelectProps> = ({
|
|
44
47
|
selectedEngine,
|
|
45
48
|
onChange,
|
|
49
|
+
cellId,
|
|
46
50
|
}) => {
|
|
47
51
|
const connectionsMap = useAtomValue(dataConnectionsMapAtom);
|
|
48
52
|
|
|
@@ -98,10 +102,10 @@ export const SQLEngineSelect: React.FC<SelectProps> = ({
|
|
|
98
102
|
return (
|
|
99
103
|
<div className="flex flex-row gap-1 items-center">
|
|
100
104
|
<Select value={selectedEngine} onValueChange={handleSelectEngine}>
|
|
101
|
-
<SQLSelectTrigger>
|
|
105
|
+
<SQLSelectTrigger {...getCellForDomProps(cellId)}>
|
|
102
106
|
<SelectValue placeholder="Select an engine" />
|
|
103
107
|
</SQLSelectTrigger>
|
|
104
|
-
<SelectContent>
|
|
108
|
+
<SelectContent {...getCellForDomProps(cellId)}>
|
|
105
109
|
<SelectGroup>
|
|
106
110
|
<SelectLabel>Database connections</SelectLabel>
|
|
107
111
|
{engineIsDisconnected && (
|
|
@@ -190,7 +190,11 @@ export const UserConfigSchema = z
|
|
|
190
190
|
})
|
|
191
191
|
// Pass through so that we don't remove any extra keys that the user has added.
|
|
192
192
|
.prefault(() => ({})),
|
|
193
|
-
server: z
|
|
193
|
+
server: z
|
|
194
|
+
.looseObject({
|
|
195
|
+
disable_file_downloads: z.boolean().optional(),
|
|
196
|
+
})
|
|
197
|
+
.prefault(() => ({})),
|
|
194
198
|
diagnostics: z
|
|
195
199
|
.looseObject({
|
|
196
200
|
enabled: z.boolean().optional(),
|
|
@@ -125,3 +125,7 @@ export const snippetsEnabledAtom = atom<boolean>((get) => {
|
|
|
125
125
|
const includeDefaultSnippets = config.snippets?.include_default_snippets;
|
|
126
126
|
return customPaths.length > 0 || includeDefaultSnippets === true;
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
export const disableFileDownloadsAtom = atom<boolean>((get) => {
|
|
130
|
+
return get(resolvedMarimoConfigAtom).server?.disable_file_downloads ?? false;
|
|
131
|
+
});
|
|
@@ -13,6 +13,7 @@ export interface ExperimentalFeatures {
|
|
|
13
13
|
chat_modes: boolean;
|
|
14
14
|
cache_panel: boolean;
|
|
15
15
|
external_agents: boolean;
|
|
16
|
+
server_side_pdf_export: boolean;
|
|
16
17
|
// Add new feature flags here
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -24,6 +25,7 @@ const defaultValues: ExperimentalFeatures = {
|
|
|
24
25
|
chat_modes: false,
|
|
25
26
|
cache_panel: false,
|
|
26
27
|
external_agents: import.meta.env.DEV,
|
|
28
|
+
server_side_pdf_export: false,
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
|
|
@@ -8,7 +8,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
8
8
|
import type { CellId } from "@/core/cells/ids";
|
|
9
9
|
import { CellOutputId } from "@/core/cells/ids";
|
|
10
10
|
import type { CellRuntimeState } from "@/core/cells/types";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
updateCellOutputsWithScreenshots,
|
|
13
|
+
useEnrichCellOutputs,
|
|
14
|
+
} from "../hooks";
|
|
12
15
|
|
|
13
16
|
// Mock html-to-image
|
|
14
17
|
vi.mock("html-to-image", () => ({
|
|
@@ -22,6 +25,11 @@ vi.mock("@/utils/Logger", () => ({
|
|
|
22
25
|
},
|
|
23
26
|
}));
|
|
24
27
|
|
|
28
|
+
// Mock toast
|
|
29
|
+
vi.mock("@/components/ui/use-toast", () => ({
|
|
30
|
+
toast: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
25
33
|
// Mock cellsRuntimeAtom - must be defined inline in the factory function
|
|
26
34
|
vi.mock("@/core/cells/cells", async () => {
|
|
27
35
|
const { atom } = await import("jotai");
|
|
@@ -31,6 +39,7 @@ vi.mock("@/core/cells/cells", async () => {
|
|
|
31
39
|
});
|
|
32
40
|
|
|
33
41
|
import { toPng } from "html-to-image";
|
|
42
|
+
import { toast } from "@/components/ui/use-toast";
|
|
34
43
|
import { cellsRuntimeAtom } from "@/core/cells/cells";
|
|
35
44
|
import { Logger } from "@/utils/Logger";
|
|
36
45
|
|
|
@@ -502,3 +511,113 @@ describe("useEnrichCellOutputs", () => {
|
|
|
502
511
|
}
|
|
503
512
|
});
|
|
504
513
|
});
|
|
514
|
+
|
|
515
|
+
describe("updateCellOutputsWithScreenshots", () => {
|
|
516
|
+
beforeEach(() => {
|
|
517
|
+
vi.clearAllMocks();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("should call updateCellOutputs when there are screenshots", async () => {
|
|
521
|
+
const cellId = "cell-1" as CellId;
|
|
522
|
+
const mockScreenshots = {
|
|
523
|
+
[cellId]: ["image/png", "data:image/png;base64,test"] as [
|
|
524
|
+
"image/png",
|
|
525
|
+
string,
|
|
526
|
+
],
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
530
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
531
|
+
|
|
532
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
533
|
+
|
|
534
|
+
expect(takeScreenshots).toHaveBeenCalledTimes(1);
|
|
535
|
+
expect(updateCellOutputs).toHaveBeenCalledTimes(1);
|
|
536
|
+
expect(updateCellOutputs).toHaveBeenCalledWith({
|
|
537
|
+
cellIdsToOutput: mockScreenshots,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("should not call updateCellOutputs when there are no screenshots", async () => {
|
|
542
|
+
const takeScreenshots = vi.fn().mockResolvedValue({});
|
|
543
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
544
|
+
|
|
545
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
546
|
+
|
|
547
|
+
expect(takeScreenshots).toHaveBeenCalledTimes(1);
|
|
548
|
+
expect(updateCellOutputs).not.toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("should handle multiple cell screenshots", async () => {
|
|
552
|
+
const cell1 = "cell-1" as CellId;
|
|
553
|
+
const cell2 = "cell-2" as CellId;
|
|
554
|
+
const mockScreenshots = {
|
|
555
|
+
[cell1]: ["image/png", "data:image/png;base64,image1"] as [
|
|
556
|
+
"image/png",
|
|
557
|
+
string,
|
|
558
|
+
],
|
|
559
|
+
[cell2]: ["image/png", "data:image/png;base64,image2"] as [
|
|
560
|
+
"image/png",
|
|
561
|
+
string,
|
|
562
|
+
],
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
566
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
567
|
+
|
|
568
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
569
|
+
|
|
570
|
+
expect(updateCellOutputs).toHaveBeenCalledWith({
|
|
571
|
+
cellIdsToOutput: mockScreenshots,
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("should catch errors from takeScreenshots and show toast", async () => {
|
|
576
|
+
const error = new Error("Screenshot failed");
|
|
577
|
+
const takeScreenshots = vi.fn().mockRejectedValue(error);
|
|
578
|
+
const updateCellOutputs = vi.fn().mockResolvedValue(null);
|
|
579
|
+
|
|
580
|
+
// Should not throw - errors are caught and shown via toast
|
|
581
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
582
|
+
|
|
583
|
+
expect(updateCellOutputs).not.toHaveBeenCalled();
|
|
584
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
585
|
+
"Error updating cell outputs with screenshots:",
|
|
586
|
+
error,
|
|
587
|
+
);
|
|
588
|
+
expect(toast).toHaveBeenCalledWith({
|
|
589
|
+
title: "Failed to capture cell outputs",
|
|
590
|
+
description:
|
|
591
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
592
|
+
variant: "danger",
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("should catch errors from updateCellOutputs and show toast", async () => {
|
|
597
|
+
const cellId = "cell-1" as CellId;
|
|
598
|
+
const mockScreenshots = {
|
|
599
|
+
[cellId]: ["image/png", "data:image/png;base64,test"] as [
|
|
600
|
+
"image/png",
|
|
601
|
+
string,
|
|
602
|
+
],
|
|
603
|
+
};
|
|
604
|
+
const error = new Error("Update failed");
|
|
605
|
+
|
|
606
|
+
const takeScreenshots = vi.fn().mockResolvedValue(mockScreenshots);
|
|
607
|
+
const updateCellOutputs = vi.fn().mockRejectedValue(error);
|
|
608
|
+
|
|
609
|
+
// Should not throw - errors are caught and shown via toast
|
|
610
|
+
await updateCellOutputsWithScreenshots(takeScreenshots, updateCellOutputs);
|
|
611
|
+
|
|
612
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
613
|
+
"Error updating cell outputs with screenshots:",
|
|
614
|
+
error,
|
|
615
|
+
);
|
|
616
|
+
expect(toast).toHaveBeenCalledWith({
|
|
617
|
+
title: "Failed to capture cell outputs",
|
|
618
|
+
description:
|
|
619
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
620
|
+
variant: "danger",
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
});
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { toPng } from "html-to-image";
|
|
3
2
|
import { atom, useAtom, useAtomValue } from "jotai";
|
|
3
|
+
import type { MimeType } from "@/components/editor/Output";
|
|
4
|
+
import { toast } from "@/components/ui/use-toast";
|
|
4
5
|
import { appConfigAtom } from "@/core/config/config";
|
|
5
6
|
import { useInterval } from "@/hooks/useInterval";
|
|
7
|
+
import { getImageDataUrlForCell } from "@/utils/download";
|
|
6
8
|
import { Logger } from "@/utils/Logger";
|
|
7
9
|
import { Objects } from "@/utils/objects";
|
|
8
10
|
import { cellsRuntimeAtom } from "../cells/cells";
|
|
9
|
-
import {
|
|
11
|
+
import type { CellId } from "../cells/ids";
|
|
10
12
|
import { connectionAtom } from "../network/connection";
|
|
11
13
|
import { useRequestClient } from "../network/requests";
|
|
14
|
+
import type { UpdateCellOutputsRequest } from "../network/types";
|
|
12
15
|
import { VirtualFileTracker } from "../static/virtual-file-tracker";
|
|
13
16
|
import { WebSocketState } from "../websocket/types";
|
|
14
17
|
|
|
@@ -60,12 +63,10 @@ export function useAutoExport() {
|
|
|
60
63
|
|
|
61
64
|
useInterval(
|
|
62
65
|
async () => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
}
|
|
66
|
+
await updateCellOutputsWithScreenshots(
|
|
67
|
+
takeScreenshots,
|
|
68
|
+
updateCellOutputs,
|
|
69
|
+
);
|
|
69
70
|
await autoExportAsIPYNB({
|
|
70
71
|
download: false,
|
|
71
72
|
});
|
|
@@ -85,6 +86,15 @@ export function useAutoExport() {
|
|
|
85
86
|
// We track cells that need screenshots, these will be exported to IPYNB
|
|
86
87
|
const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
|
|
87
88
|
|
|
89
|
+
// MIME types to capture screenshots for
|
|
90
|
+
const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
91
|
+
"text/html",
|
|
92
|
+
"application/vnd.vegalite.v5+json",
|
|
93
|
+
"application/vnd.vega.v5+json",
|
|
94
|
+
"application/vnd.vegalite.v6+json",
|
|
95
|
+
"application/vnd.vega.v6+json",
|
|
96
|
+
]);
|
|
97
|
+
|
|
88
98
|
/**
|
|
89
99
|
* Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
|
|
90
100
|
* @returns A map of cell IDs to their screenshots data.
|
|
@@ -103,7 +113,8 @@ export function useEnrichCellOutputs() {
|
|
|
103
113
|
// Track latest output for this cell
|
|
104
114
|
trackedCellsOutput[cellId] = outputData;
|
|
105
115
|
if (
|
|
106
|
-
runtime.output?.mimetype
|
|
116
|
+
runtime.output?.mimetype &&
|
|
117
|
+
MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
|
|
107
118
|
outputData &&
|
|
108
119
|
outputHasChanged
|
|
109
120
|
) {
|
|
@@ -120,16 +131,12 @@ export function useEnrichCellOutputs() {
|
|
|
120
131
|
// Capture screenshots
|
|
121
132
|
const results = await Promise.all(
|
|
122
133
|
cellsToCaptureScreenshot.map(async ([cellId]) => {
|
|
123
|
-
const outputElement = document.getElementById(
|
|
124
|
-
CellOutputId.create(cellId),
|
|
125
|
-
);
|
|
126
|
-
if (!outputElement) {
|
|
127
|
-
Logger.error(`Output element not found for cell ${cellId}`);
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
134
|
try {
|
|
132
|
-
const dataUrl = await
|
|
135
|
+
const dataUrl = await getImageDataUrlForCell(cellId);
|
|
136
|
+
if (!dataUrl) {
|
|
137
|
+
Logger.error(`Failed to capture screenshot for cell ${cellId}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
133
140
|
return [cellId, ["image/png", dataUrl]] as [
|
|
134
141
|
CellId,
|
|
135
142
|
["image/png", string],
|
|
@@ -148,3 +155,26 @@ export function useEnrichCellOutputs() {
|
|
|
148
155
|
);
|
|
149
156
|
};
|
|
150
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Utility function to take screenshots of cells with HTML outputs and update the cell outputs.
|
|
161
|
+
*/
|
|
162
|
+
export async function updateCellOutputsWithScreenshots(
|
|
163
|
+
takeScreenshots: () => Promise<Record<CellId, ["image/png", string]>>,
|
|
164
|
+
updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>,
|
|
165
|
+
) {
|
|
166
|
+
try {
|
|
167
|
+
const cellIdsToOutput = await takeScreenshots();
|
|
168
|
+
if (Object.keys(cellIdsToOutput).length > 0) {
|
|
169
|
+
await updateCellOutputs({ cellIdsToOutput });
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
Logger.error("Error updating cell outputs with screenshots:", error);
|
|
173
|
+
toast({
|
|
174
|
+
title: "Failed to capture cell outputs",
|
|
175
|
+
description:
|
|
176
|
+
"Some outputs may not appear in the PDF. Continuing with export.",
|
|
177
|
+
variant: "danger",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -168,6 +168,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
168
168
|
openTutorial = throwNotImplemented;
|
|
169
169
|
exportAsHTML = throwNotImplemented;
|
|
170
170
|
exportAsMarkdown = throwNotImplemented;
|
|
171
|
+
exportAsPDF = throwNotImplemented;
|
|
171
172
|
autoExportAsHTML = throwNotImplemented;
|
|
172
173
|
autoExportAsMarkdown = throwNotImplemented;
|
|
173
174
|
autoExportAsIPYNB = throwNotImplemented;
|
|
@@ -41,6 +41,8 @@ describe("ReconnectingWebSocketTransport", () => {
|
|
|
41
41
|
this.connect = vi.fn().mockResolvedValue(undefined);
|
|
42
42
|
this.close = vi.fn();
|
|
43
43
|
this.sendData = vi.fn().mockResolvedValue({ result: "success" });
|
|
44
|
+
this.subscribe = vi.fn();
|
|
45
|
+
this.unsubscribe = vi.fn();
|
|
44
46
|
});
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -287,4 +289,151 @@ describe("ReconnectingWebSocketTransport", () => {
|
|
|
287
289
|
"Reconnect callback failed",
|
|
288
290
|
);
|
|
289
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("subscribe", () => {
|
|
294
|
+
it("should track subscriptions", () => {
|
|
295
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
296
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
297
|
+
|
|
298
|
+
const handler = vi.fn();
|
|
299
|
+
transport.subscribe("notification", handler);
|
|
300
|
+
|
|
301
|
+
expect((transport as any).pendingSubscriptions).toHaveLength(1);
|
|
302
|
+
expect((transport as any).pendingSubscriptions[0]).toEqual({
|
|
303
|
+
event: "notification",
|
|
304
|
+
handler,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should register handler on delegate if it exists", async () => {
|
|
309
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
310
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
311
|
+
|
|
312
|
+
await transport.connect();
|
|
313
|
+
|
|
314
|
+
const handler = vi.fn();
|
|
315
|
+
transport.subscribe("notification", handler);
|
|
316
|
+
|
|
317
|
+
const delegate = (transport as any).delegate;
|
|
318
|
+
expect(delegate.subscribe).toHaveBeenCalledWith("notification", handler);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("should register pending subscriptions when delegate is created", async () => {
|
|
322
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
323
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
324
|
+
|
|
325
|
+
const handler1 = vi.fn();
|
|
326
|
+
const handler2 = vi.fn();
|
|
327
|
+
transport.subscribe("notification", handler1);
|
|
328
|
+
transport.subscribe("response", handler2);
|
|
329
|
+
|
|
330
|
+
await transport.connect();
|
|
331
|
+
|
|
332
|
+
const delegate = (transport as any).delegate;
|
|
333
|
+
expect(delegate.subscribe).toHaveBeenCalledWith("notification", handler1);
|
|
334
|
+
expect(delegate.subscribe).toHaveBeenCalledWith("response", handler2);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should re-register subscriptions on reconnection", async () => {
|
|
338
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
339
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
340
|
+
|
|
341
|
+
// Add subscription before connection
|
|
342
|
+
const handler = vi.fn();
|
|
343
|
+
transport.subscribe("notification", handler);
|
|
344
|
+
|
|
345
|
+
// First connection
|
|
346
|
+
await transport.connect();
|
|
347
|
+
const firstDelegate = (transport as any).delegate;
|
|
348
|
+
expect(firstDelegate.subscribe).toHaveBeenCalledWith(
|
|
349
|
+
"notification",
|
|
350
|
+
handler,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Clear mock calls
|
|
354
|
+
firstDelegate.subscribe.mockClear();
|
|
355
|
+
|
|
356
|
+
// Simulate connection loss
|
|
357
|
+
mockConnection.readyState = WebSocket.CLOSED;
|
|
358
|
+
|
|
359
|
+
// Reconnect by sending data
|
|
360
|
+
const data: any = { method: "test", params: [] };
|
|
361
|
+
await transport.sendData(data, 5000);
|
|
362
|
+
|
|
363
|
+
// New delegate should have been created
|
|
364
|
+
const secondDelegate = (transport as any).delegate;
|
|
365
|
+
expect(secondDelegate).not.toBe(firstDelegate);
|
|
366
|
+
|
|
367
|
+
// Subscription should be re-registered on new delegate
|
|
368
|
+
expect(secondDelegate.subscribe).toHaveBeenCalledWith(
|
|
369
|
+
"notification",
|
|
370
|
+
handler,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("unsubscribe", () => {
|
|
376
|
+
it("should remove subscription from tracking", () => {
|
|
377
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
378
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
379
|
+
|
|
380
|
+
const handler = vi.fn();
|
|
381
|
+
transport.subscribe("notification", handler);
|
|
382
|
+
expect((transport as any).pendingSubscriptions).toHaveLength(1);
|
|
383
|
+
|
|
384
|
+
transport.unsubscribe("notification", handler);
|
|
385
|
+
expect((transport as any).pendingSubscriptions).toHaveLength(0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should unregister from delegate if it exists", async () => {
|
|
389
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
390
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
391
|
+
|
|
392
|
+
await transport.connect();
|
|
393
|
+
|
|
394
|
+
const handler = vi.fn();
|
|
395
|
+
transport.subscribe("notification", handler);
|
|
396
|
+
|
|
397
|
+
const delegate = (transport as any).delegate;
|
|
398
|
+
delegate.unsubscribe.mockClear();
|
|
399
|
+
|
|
400
|
+
transport.unsubscribe("notification", handler);
|
|
401
|
+
|
|
402
|
+
expect(delegate.unsubscribe).toHaveBeenCalledWith(
|
|
403
|
+
"notification",
|
|
404
|
+
handler,
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("should not re-register unsubscribed handlers on reconnection", async () => {
|
|
409
|
+
const getWsUrl = vi.fn(() => mockWsUrl);
|
|
410
|
+
const transport = new ReconnectingWebSocketTransport({ getWsUrl });
|
|
411
|
+
|
|
412
|
+
const handler1 = vi.fn();
|
|
413
|
+
const handler2 = vi.fn();
|
|
414
|
+
transport.subscribe("notification", handler1);
|
|
415
|
+
transport.subscribe("response", handler2);
|
|
416
|
+
|
|
417
|
+
await transport.connect();
|
|
418
|
+
|
|
419
|
+
// Unsubscribe handler1
|
|
420
|
+
transport.unsubscribe("notification", handler1);
|
|
421
|
+
|
|
422
|
+
// Simulate connection loss
|
|
423
|
+
mockConnection.readyState = WebSocket.CLOSED;
|
|
424
|
+
|
|
425
|
+
// Reconnect by sending data
|
|
426
|
+
const data: any = { method: "test", params: [] };
|
|
427
|
+
await transport.sendData(data, 5000);
|
|
428
|
+
|
|
429
|
+
const newDelegate = (transport as any).delegate;
|
|
430
|
+
|
|
431
|
+
// Only handler2 should be registered on the new delegate
|
|
432
|
+
expect(newDelegate.subscribe).not.toHaveBeenCalledWith(
|
|
433
|
+
"notification",
|
|
434
|
+
handler1,
|
|
435
|
+
);
|
|
436
|
+
expect(newDelegate.subscribe).toHaveBeenCalledWith("response", handler2);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
290
439
|
});
|
|
@@ -23,6 +23,11 @@ export interface ReconnectingWebSocketTransportOptions {
|
|
|
23
23
|
onReconnect?: () => Promise<void>;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
interface Subscription {
|
|
27
|
+
event: "pending" | "notification" | "response" | "error";
|
|
28
|
+
handler: Parameters<Transport["subscribe"]>[1];
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* A WebSocket transport that automatically reconnects when the connection is lost.
|
|
28
33
|
* This handles cases like computer sleep/wake or network interruptions.
|
|
@@ -33,6 +38,7 @@ export class ReconnectingWebSocketTransport extends Transport {
|
|
|
33
38
|
private connectionPromise: Promise<void> | undefined;
|
|
34
39
|
private isClosed = false;
|
|
35
40
|
private hasConnectedBefore = false;
|
|
41
|
+
private pendingSubscriptions: Subscription[] = [];
|
|
36
42
|
|
|
37
43
|
constructor(options: ReconnectingWebSocketTransportOptions) {
|
|
38
44
|
super();
|
|
@@ -55,6 +61,12 @@ export class ReconnectingWebSocketTransport extends Transport {
|
|
|
55
61
|
|
|
56
62
|
// Create a new delegate
|
|
57
63
|
this.delegate = new WebSocketTransport(this.options.getWsUrl());
|
|
64
|
+
|
|
65
|
+
// Re-register all pending subscriptions on the new delegate
|
|
66
|
+
for (const { event, handler } of this.pendingSubscriptions) {
|
|
67
|
+
this.delegate.subscribe(event, handler);
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
return this.delegate;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -134,6 +146,42 @@ export class ReconnectingWebSocketTransport extends Transport {
|
|
|
134
146
|
this.connectionPromise = undefined;
|
|
135
147
|
}
|
|
136
148
|
|
|
149
|
+
override subscribe(...args: Parameters<Transport["subscribe"]>): void {
|
|
150
|
+
// Register handler on parent Transport
|
|
151
|
+
super.subscribe(...args);
|
|
152
|
+
|
|
153
|
+
const [event, handler] = args;
|
|
154
|
+
|
|
155
|
+
// Track the subscription
|
|
156
|
+
this.pendingSubscriptions.push({ event, handler });
|
|
157
|
+
|
|
158
|
+
// Also register on delegate if it exists
|
|
159
|
+
if (this.delegate) {
|
|
160
|
+
this.delegate.subscribe(event, handler);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override unsubscribe(
|
|
165
|
+
...args: Parameters<Transport["unsubscribe"]>
|
|
166
|
+
): import("events").EventEmitter | undefined {
|
|
167
|
+
// Unregister from parent
|
|
168
|
+
const result = super.unsubscribe(...args);
|
|
169
|
+
|
|
170
|
+
const [event, handler] = args;
|
|
171
|
+
|
|
172
|
+
// Remove from pending subscriptions
|
|
173
|
+
this.pendingSubscriptions = this.pendingSubscriptions.filter(
|
|
174
|
+
(sub) => !(sub.event === event && sub.handler === handler),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Also unregister from delegate if it exists
|
|
178
|
+
if (this.delegate) {
|
|
179
|
+
this.delegate.unsubscribe(event, handler);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
override async sendData(
|
|
138
186
|
data: JSONRPCRequestData,
|
|
139
187
|
timeout: number | null | undefined,
|
|
@@ -50,6 +50,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
|
|
|
50
50
|
// Export operations start a connection
|
|
51
51
|
exportAsHTML: "startConnection",
|
|
52
52
|
exportAsMarkdown: "startConnection",
|
|
53
|
+
exportAsPDF: "startConnection",
|
|
53
54
|
readCode: "startConnection",
|
|
54
55
|
sendCopy: "throwError",
|
|
55
56
|
|
|
@@ -381,6 +381,15 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
381
381
|
})
|
|
382
382
|
.then(handleResponse);
|
|
383
383
|
},
|
|
384
|
+
exportAsPDF: async (request) => {
|
|
385
|
+
return getClient()
|
|
386
|
+
.POST("/api/export/pdf", {
|
|
387
|
+
body: request,
|
|
388
|
+
parseAs: "blob",
|
|
389
|
+
params: getParams(),
|
|
390
|
+
})
|
|
391
|
+
.then(handleResponse);
|
|
392
|
+
},
|
|
384
393
|
autoExportAsHTML: async (request) => {
|
|
385
394
|
return getClient()
|
|
386
395
|
.POST("/api/export/auto_export/html", {
|
|
@@ -76,6 +76,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
76
76
|
shutdownSession: throwNotInEditMode,
|
|
77
77
|
exportAsHTML: throwNotInEditMode,
|
|
78
78
|
exportAsMarkdown: throwNotInEditMode,
|
|
79
|
+
exportAsPDF: throwNotInEditMode,
|
|
79
80
|
autoExportAsHTML: throwNotInEditMode,
|
|
80
81
|
autoExportAsMarkdown: throwNotInEditMode,
|
|
81
82
|
autoExportAsIPYNB: throwNotInEditMode,
|
|
@@ -61,6 +61,7 @@ export function createErrorToastingRequests(
|
|
|
61
61
|
shutdownSession: "Failed to shutdown session",
|
|
62
62
|
exportAsHTML: "Failed to export HTML",
|
|
63
63
|
exportAsMarkdown: "Failed to export Markdown",
|
|
64
|
+
exportAsPDF: "Failed to export PDF",
|
|
64
65
|
autoExportAsHTML: "", // No toast
|
|
65
66
|
autoExportAsMarkdown: "", // No toast
|
|
66
67
|
autoExportAsIPYNB: "", // No toast
|
|
@@ -21,6 +21,7 @@ export type ExportAsHTMLRequest = schemas["ExportAsHTMLRequest"];
|
|
|
21
21
|
export type ExportAsMarkdownRequest = schemas["ExportAsMarkdownRequest"];
|
|
22
22
|
export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
|
|
23
23
|
export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
|
|
24
|
+
export type ExportAsPDFRequest = schemas["ExportAsPDFRequest"];
|
|
24
25
|
export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
|
|
25
26
|
export type FileCreateRequest = schemas["FileCreateRequest"];
|
|
26
27
|
export type FileCreateResponse = schemas["FileCreateResponse"];
|
|
@@ -173,6 +174,7 @@ export interface EditRequests {
|
|
|
173
174
|
// Export requests
|
|
174
175
|
exportAsHTML: (request: ExportAsHTMLRequest) => Promise<string>;
|
|
175
176
|
exportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<string>;
|
|
177
|
+
exportAsPDF: (request: ExportAsPDFRequest) => Promise<Blob>;
|
|
176
178
|
autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
|
|
177
179
|
autoExportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<null>;
|
|
178
180
|
autoExportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<null>;
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -586,6 +586,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
586
586
|
getWorkspaceFiles = throwNotImplemented;
|
|
587
587
|
getRunningNotebooks = throwNotImplemented;
|
|
588
588
|
shutdownSession = throwNotImplemented;
|
|
589
|
+
exportAsPDF = throwNotImplemented;
|
|
589
590
|
autoExportAsHTML = throwNotImplemented;
|
|
590
591
|
autoExportAsMarkdown = throwNotImplemented;
|
|
591
592
|
autoExportAsIPYNB = throwNotImplemented;
|