@marimo-team/islands 0.23.12-dev1 → 0.23.12-dev10
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-WqG-xX4l.js → ConnectedDataExplorerComponent-Du3_nUzI.js} +13 -13
- package/dist/{ErrorBoundary-BNx_OSVo.js → ErrorBoundary-DE6tzZf-.js} +2 -2
- package/dist/{any-language-editor-rPSlOll9.js → any-language-editor-DN1R-1KZ.js} +5 -5
- package/dist/{button-vQhauTmO.js → button-BacYv-bE.js} +7 -1
- package/dist/{capabilities-BEHzIS99.js → capabilities-D_4LYhSU.js} +1 -1
- package/dist/{chat-ui-k2kqhCv5.js → chat-ui-BEOvjkmJ.js} +16 -16
- package/dist/{check-nrzHDi45.js → check-C9OoNtR4.js} +1 -1
- package/dist/{code-visibility-CqnbA4Ex.js → code-visibility-D1W_9sHW.js} +664 -663
- package/dist/{copy-UhDed7D4.js → copy-COam1EG7.js} +2 -2
- package/dist/{dist-DYGLrbYQ.js → dist--2Bqjvs0.js} +2 -2
- package/dist/{error-banner-BHAkVFc2.js → error-banner-DFPfz_Qf.js} +2 -2
- package/dist/{esm-Bqu9AE2K.js → esm-M837UxV5.js} +1 -1
- package/dist/{extends-9Yl5BEcg.js → extends-9MVIxxRo.js} +4 -4
- package/dist/{formats-BV4bOfMI.js → formats-d6MhLuQ9.js} +4 -4
- package/dist/{glide-data-editor-BDTq6YUb.js → glide-data-editor-DkzAInWG.js} +9 -9
- package/dist/{html-to-image-C86pQALH.js → html-to-image-Di0mtt6O.js} +73 -73
- package/dist/{input-AKkGXdyV.js → input-CbEz_aj_.js} +6 -6
- package/dist/{label-E3ZJXHu8.js → label-WfTSU8L4.js} +2 -2
- package/dist/{loader-YPuQvn1Y.js → loader-Boph2xIS.js} +1 -1
- package/dist/main.js +1619 -1592
- package/dist/{mermaid-QFAR9YgY.js → mermaid-CJW9vIyO.js} +5 -5
- package/dist/{process-output-nNw4OpSj.js → process-output-BLd4KuwX.js} +3 -3
- package/dist/{reveal-component-CKXMgI4_.js → reveal-component-BXP8Y-bq.js} +11 -11
- package/dist/{spec-B45_YCNI.js → spec-Bv-XlYiv.js} +4 -4
- package/dist/{strings-Cq2s9_EQ.js → strings-Dq_j3Rxw.js} +4 -4
- package/dist/style.css +2 -2
- package/dist/{swiper-component-BNa_4kh2.js → swiper-component-5HoSsPi1.js} +2 -2
- package/dist/{toDate-Do1xRzAo.js → toDate-D-l5s8nn.js} +3 -3
- package/dist/{tooltip-Bz3OAwrU.js → tooltip-Czds6Qr8.js} +3 -3
- package/dist/{types-D8gEGs4R.js → types-C2Ir191_.js} +1 -1
- package/dist/{useAsyncData-CL3o2p4i.js → useAsyncData-1Dhzjfwf.js} +1 -1
- package/dist/{useDateFormatter-BC6iSz9g.js → useDateFormatter-CMnRuVmN.js} +2 -2
- package/dist/{useDeepCompareMemoize-BPx2MuOK.js → useDeepCompareMemoize-CDWT3BDz.js} +1 -1
- package/dist/{useIframeCapabilities-C6Ta3EyP.js → useIframeCapabilities-DWIYvDh7.js} +1 -1
- package/dist/{useLifecycle-C3Ec71q0.js → useLifecycle-AHlswLw-.js} +3 -3
- package/dist/{useTheme-ZhT6uIu3.js → useTheme-BrYvK-_A.js} +2 -2
- package/dist/{vega-component-C3AWYGAL.js → vega-component-Pk6lyc_a.js} +10 -10
- package/dist/{zod-DXqkaI_w.js → zod-CijjQh4u.js} +1 -1
- package/package.json +3 -3
- package/src/components/ai/display-helpers.tsx +5 -5
- package/src/components/app-config/ai-config.tsx +5 -5
- package/src/components/app-config/mcp-config.tsx +3 -3
- package/src/components/chat/acp/agent-panel.tsx +3 -3
- package/src/components/chat/acp/blocks.tsx +36 -38
- package/src/components/chat/acp/common.tsx +12 -16
- package/src/components/chat/acp/scroll-to-bottom-button.tsx +1 -1
- package/src/components/chat/acp/session-tabs.tsx +2 -2
- package/src/components/chat/chat-history-popover.tsx +1 -1
- package/src/components/chat/chat-panel.tsx +47 -23
- package/src/components/data-table/TableBottomBar.tsx +4 -1
- package/src/components/data-table/columns.tsx +2 -2
- package/src/components/data-table/data-table.tsx +26 -17
- package/src/components/data-table/filter-pill-editor.tsx +1 -1
- package/src/components/dependency-graph/minimap-content.tsx +1 -1
- package/src/components/editor/RecoveryButton.tsx +1 -1
- package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
- package/src/components/editor/actions/useNotebookActions.tsx +4 -4
- package/src/components/editor/ai/__tests__/completion-utils.test.ts +91 -1
- package/src/components/editor/ai/ai-completion-editor.tsx +1 -1
- package/src/components/editor/ai/completion-utils.ts +86 -1
- package/src/components/editor/cell/CreateCellButton.tsx +1 -1
- package/src/components/editor/chrome/panels/empty-state.tsx +1 -1
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +1 -1
- package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +1 -1
- package/src/components/editor/columns/cell-column.tsx +1 -1
- package/src/components/editor/columns/sortable-column.tsx +2 -2
- package/src/components/editor/output/MarimoErrorOutput.tsx +1 -1
- package/src/components/editor/output/TextOutput.tsx +2 -2
- package/src/components/home/components.tsx +4 -4
- package/src/components/icons/github.tsx +21 -0
- package/src/components/icons/youtube.tsx +21 -0
- package/src/components/slides/minimap.tsx +2 -2
- package/src/components/slides/reveal-component.tsx +1 -1
- package/src/components/storage/components.tsx +3 -7
- package/src/components/ui/alert.tsx +1 -1
- package/src/components/ui/command.tsx +2 -2
- package/src/components/ui/reorderable-list.tsx +1 -1
- package/src/components/ui/table.tsx +2 -5
- package/src/core/codemirror/language/languages/sql/renderers.tsx +60 -68
- package/src/core/hotkeys/hotkeys.ts +1 -0
- package/src/plugins/impl/DataTablePlugin.tsx +20 -1
- package/src/plugins/impl/FileBrowserPlugin.tsx +165 -74
- package/src/plugins/impl/MatrixPlugin.tsx +2 -2
- package/src/plugins/impl/TabsPlugin.tsx +1 -1
- package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +141 -1
- package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +314 -0
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -1
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +155 -98
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +154 -1
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +10 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { SetupMocks } from "@/__mocks__/common";
|
|
6
|
+
import { initialModeAtom } from "@/core/mode";
|
|
7
|
+
import { store } from "@/core/state/jotai";
|
|
8
|
+
import type { IPluginProps } from "../../types";
|
|
9
|
+
import { FileBrowserPlugin } from "../FileBrowserPlugin";
|
|
10
|
+
|
|
11
|
+
interface MockFile {
|
|
12
|
+
id: string;
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
is_directory: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FILES: MockFile[] = [
|
|
19
|
+
{ id: "1", path: "/home/user/docs", name: "docs", is_directory: true },
|
|
20
|
+
{ id: "2", path: "/home/user/a.txt", name: "a.txt", is_directory: false },
|
|
21
|
+
{ id: "3", path: "/home/user/b.txt", name: "b.txt", is_directory: false },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
type Value = MockFile[];
|
|
25
|
+
|
|
26
|
+
function mockListDirectory(files: MockFile[]) {
|
|
27
|
+
return vi.fn().mockResolvedValue({
|
|
28
|
+
files,
|
|
29
|
+
total_count: files.length,
|
|
30
|
+
is_truncated: false,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeProps(
|
|
35
|
+
overrides: {
|
|
36
|
+
selectionMode?: string;
|
|
37
|
+
multiple?: boolean;
|
|
38
|
+
value?: Value;
|
|
39
|
+
setValue?: (v: Value) => void;
|
|
40
|
+
files?: MockFile[];
|
|
41
|
+
list_directory?: ReturnType<typeof vi.fn>;
|
|
42
|
+
host?: HTMLElement;
|
|
43
|
+
} = {},
|
|
44
|
+
): IPluginProps<Value, Record<string, unknown>> {
|
|
45
|
+
const files = overrides.files ?? FILES;
|
|
46
|
+
const list_directory = overrides.list_directory ?? mockListDirectory(files);
|
|
47
|
+
return {
|
|
48
|
+
data: {
|
|
49
|
+
initialPath: "/home/user",
|
|
50
|
+
filetypes: [],
|
|
51
|
+
selectionMode: overrides.selectionMode ?? "all",
|
|
52
|
+
multiple: overrides.multiple ?? true,
|
|
53
|
+
label: null,
|
|
54
|
+
restrictNavigation: false,
|
|
55
|
+
},
|
|
56
|
+
value: overrides.value ?? [],
|
|
57
|
+
setValue: overrides.setValue ?? vi.fn(),
|
|
58
|
+
host: overrides.host ?? document.createElement("div"),
|
|
59
|
+
functions: {
|
|
60
|
+
list_directory,
|
|
61
|
+
},
|
|
62
|
+
} as unknown as IPluginProps<Value, Record<string, unknown>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderBrowser(overrides: Parameters<typeof makeProps>[0] = {}) {
|
|
66
|
+
const listDirectory =
|
|
67
|
+
overrides.list_directory ?? mockListDirectory(overrides.files ?? FILES);
|
|
68
|
+
const result = render(
|
|
69
|
+
FileBrowserPlugin.render(
|
|
70
|
+
makeProps({ ...overrides, list_directory: listDirectory }) as Parameters<
|
|
71
|
+
typeof FileBrowserPlugin.render
|
|
72
|
+
>[0],
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
return { ...result, listDirectory };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(() => {
|
|
79
|
+
SetupMocks.resizeObserver();
|
|
80
|
+
store.set(initialModeAtom, "edit");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("FileBrowserPlugin keyboard accessibility", () => {
|
|
84
|
+
it("renders a row per file plus the parent row", async () => {
|
|
85
|
+
renderBrowser();
|
|
86
|
+
expect(await screen.findByText("docs")).toBeInTheDocument();
|
|
87
|
+
// parent "..", docs, a.txt, b.txt
|
|
88
|
+
expect(screen.getAllByRole("row")).toHaveLength(4);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("marks the list as a multiselectable grid", async () => {
|
|
92
|
+
renderBrowser({ multiple: true });
|
|
93
|
+
await screen.findByText("docs");
|
|
94
|
+
const grid = screen.getByRole("grid");
|
|
95
|
+
expect(grid).toHaveAttribute("aria-multiselectable", "true");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not select a non-selectable file on click (mode=directory)", async () => {
|
|
99
|
+
const setValue = vi.fn();
|
|
100
|
+
renderBrowser({ selectionMode: "directory", setValue });
|
|
101
|
+
const fileCell = await screen.findByText("a.txt");
|
|
102
|
+
fireEvent.click(fileCell.closest('[role="row"]')!);
|
|
103
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("labels each selectable checkbox with the file name", async () => {
|
|
107
|
+
renderBrowser({ selectionMode: "all" });
|
|
108
|
+
await screen.findByText("docs");
|
|
109
|
+
expect(
|
|
110
|
+
screen.getByRole("checkbox", { name: "Select a.txt" }),
|
|
111
|
+
).toBeInTheDocument();
|
|
112
|
+
expect(
|
|
113
|
+
screen.getByRole("checkbox", { name: "Select docs" }),
|
|
114
|
+
).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps checkboxes out of the tab order", async () => {
|
|
118
|
+
renderBrowser({ selectionMode: "all" });
|
|
119
|
+
await screen.findByText("docs");
|
|
120
|
+
expect(
|
|
121
|
+
screen.getByRole("checkbox", { name: "Select a.txt" }),
|
|
122
|
+
).toHaveAttribute("tabindex", "-1");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("places exactly one row in the tab order", async () => {
|
|
126
|
+
renderBrowser();
|
|
127
|
+
await screen.findByText("docs");
|
|
128
|
+
const rows = screen.getAllByRole("row");
|
|
129
|
+
const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
|
|
130
|
+
expect(tabbable).toHaveLength(1);
|
|
131
|
+
// the parent row is first and starts active
|
|
132
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resets the active row to the parent row after navigating", async () => {
|
|
136
|
+
renderBrowser({ selectionMode: "all" });
|
|
137
|
+
const docs = await screen.findByText("docs");
|
|
138
|
+
const docsRow = docs.closest('[role="row"]')!;
|
|
139
|
+
fireEvent.keyDown(docsRow, { key: "ArrowDown" }); // move active off the parent
|
|
140
|
+
fireEvent.click(docs); // navigate into "docs"
|
|
141
|
+
await screen.findByText("docs"); // listing reloads (mock returns same files)
|
|
142
|
+
const rows = screen.getAllByRole("row");
|
|
143
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
144
|
+
// the focused row must match the only tabbable row
|
|
145
|
+
expect(rows[0]).toHaveFocus();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("resets the active row when the listing refreshes in place", async () => {
|
|
149
|
+
// The cell's random-id changes when the cell re-renders, refetching the
|
|
150
|
+
// same path. A shrinking listing must not leave activeIndex out of bounds.
|
|
151
|
+
const parent = document.createElement("div");
|
|
152
|
+
parent.setAttribute("random-id", "a");
|
|
153
|
+
const host = document.createElement("div");
|
|
154
|
+
parent.append(host);
|
|
155
|
+
document.body.append(parent);
|
|
156
|
+
|
|
157
|
+
const list_directory = vi
|
|
158
|
+
.fn()
|
|
159
|
+
.mockResolvedValueOnce({
|
|
160
|
+
files: FILES,
|
|
161
|
+
total_count: FILES.length,
|
|
162
|
+
is_truncated: false,
|
|
163
|
+
})
|
|
164
|
+
.mockResolvedValue({
|
|
165
|
+
files: [FILES[0]],
|
|
166
|
+
total_count: 1,
|
|
167
|
+
is_truncated: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const props = () =>
|
|
171
|
+
FileBrowserPlugin.render(
|
|
172
|
+
makeProps({ host, list_directory }) as Parameters<
|
|
173
|
+
typeof FileBrowserPlugin.render
|
|
174
|
+
>[0],
|
|
175
|
+
);
|
|
176
|
+
const { rerender } = render(props());
|
|
177
|
+
await screen.findByText("a.txt");
|
|
178
|
+
|
|
179
|
+
// Move the active row to the last row, then trigger a same-path refresh.
|
|
180
|
+
fireEvent.keyDown(screen.getAllByRole("row")[0], { key: "End" });
|
|
181
|
+
expect(screen.getAllByRole("row").at(-1)).toHaveAttribute("tabindex", "0");
|
|
182
|
+
|
|
183
|
+
parent.setAttribute("random-id", "b");
|
|
184
|
+
rerender(props());
|
|
185
|
+
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
// parent ".." plus the single remaining file
|
|
188
|
+
expect(screen.getAllByRole("row")).toHaveLength(2),
|
|
189
|
+
);
|
|
190
|
+
const rows = screen.getAllByRole("row");
|
|
191
|
+
const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
|
|
192
|
+
expect(tabbable).toHaveLength(1);
|
|
193
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("syncs the tabbable row to whichever row gains focus", async () => {
|
|
197
|
+
renderBrowser();
|
|
198
|
+
await screen.findByText("docs");
|
|
199
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
200
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
201
|
+
|
|
202
|
+
fireEvent.focus(rows[2]); // e.g. focus from a mouse click, not arrow keys
|
|
203
|
+
expect(rows[2]).toHaveAttribute("tabindex", "0");
|
|
204
|
+
expect(rows[0]).toHaveAttribute("tabindex", "-1");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("moves focus to the parent row after navigating from within the grid", async () => {
|
|
208
|
+
// Navigating into a directory unmounts the focused row; focus must follow
|
|
209
|
+
// to the parent row instead of falling back to the document body.
|
|
210
|
+
const list_directory = vi
|
|
211
|
+
.fn()
|
|
212
|
+
.mockResolvedValueOnce({
|
|
213
|
+
files: FILES,
|
|
214
|
+
total_count: FILES.length,
|
|
215
|
+
is_truncated: false,
|
|
216
|
+
})
|
|
217
|
+
.mockResolvedValue({
|
|
218
|
+
files: [
|
|
219
|
+
{
|
|
220
|
+
id: "99",
|
|
221
|
+
path: "/home/user/docs/inner.txt",
|
|
222
|
+
name: "inner.txt",
|
|
223
|
+
is_directory: false,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
total_count: 1,
|
|
227
|
+
is_truncated: false,
|
|
228
|
+
});
|
|
229
|
+
renderBrowser({ selectionMode: "all", list_directory });
|
|
230
|
+
await screen.findByText("docs");
|
|
231
|
+
|
|
232
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
233
|
+
fireEvent.keyDown(rows[0], { key: "ArrowDown" }); // focus the docs row
|
|
234
|
+
expect(rows[1]).toHaveFocus();
|
|
235
|
+
fireEvent.keyDown(rows[1], { key: "Enter" }); // navigate into docs
|
|
236
|
+
|
|
237
|
+
await screen.findByText("inner.txt");
|
|
238
|
+
expect(screen.getAllByRole("row")[0]).toHaveFocus();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("moves focus with arrows and clamps at the ends", async () => {
|
|
242
|
+
renderBrowser();
|
|
243
|
+
await screen.findByText("docs");
|
|
244
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
245
|
+
|
|
246
|
+
fireEvent.keyDown(rows[0], { key: "ArrowDown" });
|
|
247
|
+
expect(rows[1]).toHaveFocus();
|
|
248
|
+
expect(rows[1]).toHaveAttribute("tabindex", "0");
|
|
249
|
+
|
|
250
|
+
fireEvent.keyDown(rows[1], { key: "ArrowUp" });
|
|
251
|
+
expect(rows[0]).toHaveFocus();
|
|
252
|
+
|
|
253
|
+
fireEvent.keyDown(rows[0], { key: "ArrowUp" }); // clamp at top
|
|
254
|
+
expect(rows[0]).toHaveFocus();
|
|
255
|
+
|
|
256
|
+
fireEvent.keyDown(rows[0], { key: "End" });
|
|
257
|
+
expect(rows[3]).toHaveFocus();
|
|
258
|
+
|
|
259
|
+
fireEvent.keyDown(rows[3], { key: "ArrowDown" }); // clamp at bottom
|
|
260
|
+
expect(rows[3]).toHaveFocus();
|
|
261
|
+
|
|
262
|
+
fireEvent.keyDown(rows[3], { key: "Home" });
|
|
263
|
+
expect(rows[0]).toHaveFocus();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("Enter navigates into a directory", async () => {
|
|
267
|
+
const setValue = vi.fn();
|
|
268
|
+
const { listDirectory } = renderBrowser({
|
|
269
|
+
selectionMode: "all",
|
|
270
|
+
setValue,
|
|
271
|
+
});
|
|
272
|
+
await screen.findByText("docs");
|
|
273
|
+
fireEvent.keyDown(rowFor("docs"), { key: "Enter" });
|
|
274
|
+
await waitFor(() =>
|
|
275
|
+
expect(listDirectory).toHaveBeenCalledWith({ path: "/home/user/docs" }),
|
|
276
|
+
);
|
|
277
|
+
// navigation does not mutate value
|
|
278
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("Enter toggles selection on a selectable file", async () => {
|
|
282
|
+
const setValue = vi.fn();
|
|
283
|
+
renderBrowser({ selectionMode: "all", multiple: true, setValue });
|
|
284
|
+
await screen.findByText("a.txt");
|
|
285
|
+
fireEvent.keyDown(rowFor("a.txt"), { key: "Enter" });
|
|
286
|
+
expect(setValue).toHaveBeenCalledWith([
|
|
287
|
+
expect.objectContaining({ path: "/home/user/a.txt" }),
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("Space toggles selection and never navigates", async () => {
|
|
292
|
+
const setValue = vi.fn();
|
|
293
|
+
renderBrowser({ selectionMode: "all", multiple: true, setValue });
|
|
294
|
+
await screen.findByText("docs");
|
|
295
|
+
// Space on a selectable directory selects it (does not navigate)
|
|
296
|
+
fireEvent.keyDown(rowFor("docs"), { key: " " });
|
|
297
|
+
expect(setValue).toHaveBeenCalledWith([
|
|
298
|
+
expect.objectContaining({ path: "/home/user/docs" }),
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("Space on the parent row is a no-op", async () => {
|
|
303
|
+
const setValue = vi.fn();
|
|
304
|
+
renderBrowser({ selectionMode: "all", setValue });
|
|
305
|
+
await screen.findByText("docs");
|
|
306
|
+
const parentRow = screen.getAllByRole("row")[0];
|
|
307
|
+
fireEvent.keyDown(parentRow, { key: " " });
|
|
308
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
function rowFor(name: string): HTMLElement {
|
|
313
|
+
return screen.getByText(name).closest('[role="row"]') as HTMLElement;
|
|
314
|
+
}
|
|
@@ -292,7 +292,7 @@ export class MatplotlibRenderer {
|
|
|
292
292
|
// Configure container
|
|
293
293
|
container.tabIndex = -1;
|
|
294
294
|
container.role = "application";
|
|
295
|
-
container.className = "relative inline-block select-none outline-
|
|
295
|
+
container.className = "relative inline-block select-none outline-hidden";
|
|
296
296
|
|
|
297
297
|
// Create canvas
|
|
298
298
|
const canvas = document.createElement("canvas");
|
|
@@ -174,99 +174,99 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
174
174
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
175
175
|
const figureRef = useRef<MplFigure | null>(null);
|
|
176
176
|
const wsRef = useRef<MplCommWebSocket | null>(null);
|
|
177
|
+
// Sends to the currently bound backend model. Re-pointed on every (re)bind
|
|
178
|
+
// so the persistent socket and toolbar downloads always reach the live comm.
|
|
179
|
+
const sendRef = useRef<(msg: unknown) => void>(Functions.NOOP);
|
|
180
|
+
// Detaches the model bound by the most recent bindModel call. Shared between
|
|
181
|
+
// the mount and rebind effects so a rerun disposes the prior model's
|
|
182
|
+
// listener before attaching the next one (never stacking listeners).
|
|
183
|
+
const boundModelCleanupRef = useRef<(() => void) | undefined>(undefined);
|
|
184
|
+
// Latest model id, read by the mount effect without being a dependency:
|
|
185
|
+
// the figure is built once, and switching to a new model is the rebind
|
|
186
|
+
// effect's job, not a reason to tear the canvas down.
|
|
187
|
+
const modelIdRef = useRef(modelId);
|
|
188
|
+
modelIdRef.current = modelId;
|
|
189
|
+
// The data attributes are re-parsed into fresh objects on every rerun, so
|
|
190
|
+
// `toolbarImages` changes identity even when its contents do not. Read it
|
|
191
|
+
// from a ref so it can't retrigger the mount effect and rebuild the canvas.
|
|
192
|
+
const toolbarImagesRef = useRef(toolbarImages);
|
|
193
|
+
toolbarImagesRef.current = toolbarImages;
|
|
194
|
+
|
|
195
|
+
// Bind the already-rendered figure/socket to a backend model, leaving the
|
|
196
|
+
// DOM, figure, and socket in place so they survive across reruns. Disposes
|
|
197
|
+
// the previously bound model first and records the new cleanup in
|
|
198
|
+
// boundModelCleanupRef, so exactly one model listener is ever attached.
|
|
199
|
+
const bindModel = useCallback(async (id: WidgetModelId): Promise<void> => {
|
|
200
|
+
const fakeWs = wsRef.current;
|
|
201
|
+
if (!fakeWs) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
177
204
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
let model: Model<ModelState>;
|
|
206
|
+
try {
|
|
207
|
+
model = await MODEL_MANAGER.get(id);
|
|
208
|
+
} catch {
|
|
209
|
+
Logger.error("Failed to get model for mpl interactive", id);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
182
212
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
213
|
+
// The figure may have been torn down (unmount, or a structural rebuild)
|
|
214
|
+
// while we awaited the model; don't wire a listener that would outlive it.
|
|
215
|
+
if (wsRef.current !== fakeWs) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
187
218
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
219
|
+
// Detach the previously bound model before wiring the new one.
|
|
220
|
+
boundModelCleanupRef.current?.();
|
|
221
|
+
|
|
222
|
+
// Re-point outbound traffic at this model without recreating the socket,
|
|
223
|
+
// so mpl.js's onopen/onmessage wiring stays intact.
|
|
224
|
+
const send = (msg: unknown) => model.send(msg);
|
|
225
|
+
fakeWs.setSendHandler(send);
|
|
226
|
+
sendRef.current = send;
|
|
227
|
+
|
|
228
|
+
// Listen for backend → frontend messages via model custom events
|
|
229
|
+
const handleCustomMessage = (
|
|
230
|
+
content: { type: string; data?: unknown; format?: string },
|
|
231
|
+
buffers?: readonly DataView[],
|
|
232
|
+
) => {
|
|
233
|
+
if (!content) {
|
|
194
234
|
return;
|
|
195
235
|
}
|
|
196
236
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (content.type === "json") {
|
|
214
|
-
fakeWs.receiveJson(content.data);
|
|
215
|
-
} else if (content.type === "binary" && buffers && buffers.length > 0) {
|
|
216
|
-
fakeWs.receiveBinary(buffers[0]);
|
|
217
|
-
} else if (
|
|
218
|
-
content.type === "download" &&
|
|
219
|
-
buffers &&
|
|
220
|
-
buffers.length > 0
|
|
221
|
-
) {
|
|
222
|
-
const fmt = content.format || "png";
|
|
223
|
-
const dv = buffers[0];
|
|
224
|
-
const ab = dv.buffer.slice(
|
|
225
|
-
dv.byteOffset,
|
|
226
|
-
dv.byteOffset + dv.byteLength,
|
|
227
|
-
) as ArrayBuffer;
|
|
228
|
-
downloadBlob(
|
|
229
|
-
new Blob([ab], { type: `image/${fmt}` }),
|
|
230
|
-
`figure.${fmt}`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
};
|
|
237
|
+
if (content.type === "json") {
|
|
238
|
+
fakeWs.receiveJson(content.data);
|
|
239
|
+
} else if (content.type === "binary" && buffers && buffers.length > 0) {
|
|
240
|
+
fakeWs.receiveBinary(buffers[0]);
|
|
241
|
+
} else if (content.type === "download" && buffers && buffers.length > 0) {
|
|
242
|
+
const fmt = content.format || "png";
|
|
243
|
+
const dv = buffers[0];
|
|
244
|
+
const ab = dv.buffer.slice(
|
|
245
|
+
dv.byteOffset,
|
|
246
|
+
dv.byteOffset + dv.byteLength,
|
|
247
|
+
) as ArrayBuffer;
|
|
248
|
+
downloadBlob(new Blob([ab], { type: `image/${fmt}` }), `figure.${fmt}`);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
234
251
|
|
|
235
|
-
|
|
252
|
+
model.on("msg:custom", handleCustomMessage as any);
|
|
236
253
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
model.send({ type: "download", format });
|
|
242
|
-
};
|
|
254
|
+
// Replay the mpl.js handshake against the new comm so the backend
|
|
255
|
+
// re-establishes image mode and pushes a frame. The figure DOM and the
|
|
256
|
+
// backend manager's toolbar state are left untouched.
|
|
257
|
+
fakeWs.onopen?.();
|
|
243
258
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// ResizeObserver doesn't trigger an immediate resize cycle.
|
|
249
|
-
// mpl.js creates: fig.root > [titlebar, canvas_div, toolbar]
|
|
250
|
-
const canvasDiv = fig.root.querySelector<HTMLElement>("div[tabindex]");
|
|
251
|
-
if (canvasDiv) {
|
|
252
|
-
canvasDiv.style.width = `${width}px`;
|
|
253
|
-
canvasDiv.style.height = `${height}px`;
|
|
259
|
+
boundModelCleanupRef.current = () => {
|
|
260
|
+
model.off("msg:custom", handleCustomMessage as any);
|
|
261
|
+
if (sendRef.current === send) {
|
|
262
|
+
sendRef.current = Functions.NOOP;
|
|
254
263
|
}
|
|
264
|
+
};
|
|
265
|
+
}, []);
|
|
255
266
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
fakeWs.onopen?.();
|
|
260
|
-
}, 0);
|
|
261
|
-
|
|
262
|
-
return () => {
|
|
263
|
-
model.off("msg:custom", handleCustomMessage as any);
|
|
264
|
-
fakeWs.close();
|
|
265
|
-
};
|
|
266
|
-
},
|
|
267
|
-
[modelId, mplJsUrl, width, height],
|
|
268
|
-
);
|
|
269
|
-
|
|
267
|
+
// Mount: build the DOM, mpl figure, and socket once. modelId is read from a
|
|
268
|
+
// ref and intentionally omitted from the deps — rebinding to a new model is
|
|
269
|
+
// handled by the effect below, not by rebuilding the canvas.
|
|
270
270
|
useEffect(() => {
|
|
271
271
|
const container = containerRef.current;
|
|
272
272
|
if (!container) {
|
|
@@ -280,34 +280,90 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
280
280
|
const removeCss = injectCss(container, cssUrl);
|
|
281
281
|
|
|
282
282
|
// Patch toolbar images
|
|
283
|
-
const removeImageObserver = patchToolbarImages(
|
|
283
|
+
const removeImageObserver = patchToolbarImages(
|
|
284
|
+
container,
|
|
285
|
+
toolbarImagesRef.current,
|
|
286
|
+
);
|
|
284
287
|
|
|
285
|
-
let cleanup: (() => void) | undefined;
|
|
286
288
|
let cancelled = false;
|
|
287
289
|
|
|
288
|
-
|
|
289
|
-
.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
290
|
+
const setup = async () => {
|
|
291
|
+
// Load mpl.js globally (only once, via <script src>)
|
|
292
|
+
await ensureMplJs(mplJsUrl);
|
|
293
|
+
|
|
294
|
+
if (!window.mpl) {
|
|
295
|
+
Logger.error("mpl.js failed to load");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The send handler is swapped per bind; route through sendRef so the
|
|
300
|
+
// socket outlives any single model.
|
|
301
|
+
const fakeWs = new MplCommWebSocket((msg: unknown) => {
|
|
302
|
+
sendRef.current(msg);
|
|
300
303
|
});
|
|
304
|
+
wsRef.current = fakeWs;
|
|
305
|
+
|
|
306
|
+
const ondownload = (_figure: MplFigure, format: string) => {
|
|
307
|
+
sendRef.current({ type: "download", format });
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const fig = new window.mpl.figure(
|
|
311
|
+
modelIdRef.current,
|
|
312
|
+
fakeWs,
|
|
313
|
+
ondownload,
|
|
314
|
+
container,
|
|
315
|
+
);
|
|
316
|
+
figureRef.current = fig;
|
|
317
|
+
|
|
318
|
+
// Set the canvas_div to the backend's figure size so the
|
|
319
|
+
// ResizeObserver doesn't trigger an immediate resize cycle.
|
|
320
|
+
// mpl.js creates: fig.root > [titlebar, canvas_div, toolbar]
|
|
321
|
+
const canvasDiv = fig.root.querySelector<HTMLElement>("div[tabindex]");
|
|
322
|
+
if (canvasDiv) {
|
|
323
|
+
canvasDiv.style.width = `${width}px`;
|
|
324
|
+
canvasDiv.style.height = `${height}px`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await bindModel(modelIdRef.current);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
setup().catch((error) => {
|
|
331
|
+
if (!cancelled) {
|
|
332
|
+
Logger.error("Failed to set up MPL interactive figure", error);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
301
335
|
|
|
302
336
|
return () => {
|
|
303
337
|
cancelled = true;
|
|
338
|
+
boundModelCleanupRef.current?.();
|
|
339
|
+
boundModelCleanupRef.current = undefined;
|
|
304
340
|
removeCss();
|
|
305
341
|
removeImageObserver();
|
|
306
|
-
|
|
342
|
+
wsRef.current?.close();
|
|
343
|
+
wsRef.current = null;
|
|
344
|
+
figureRef.current = null;
|
|
307
345
|
// Clear DOM on unmount so stale content doesn't linger
|
|
308
346
|
container.innerHTML = "";
|
|
309
347
|
};
|
|
310
|
-
}, [
|
|
348
|
+
}, [mplJsUrl, cssUrl, width, height, bindModel]);
|
|
349
|
+
|
|
350
|
+
// Rebind to a new model when the cell re-runs, keeping the rendered figure
|
|
351
|
+
// and toolbar in place. The initial bind is owned by the mount effect, so
|
|
352
|
+
// skip the first run here.
|
|
353
|
+
const isInitialBindRef = useRef(true);
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (isInitialBindRef.current) {
|
|
356
|
+
isInitialBindRef.current = false;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// bindModel disposes the previously bound model and guards against a
|
|
361
|
+
// teardown that races the awaited model lookup, so no per-run cleanup is
|
|
362
|
+
// needed here; the mount effect's cleanup detaches the final bind.
|
|
363
|
+
bindModel(modelId).catch((error) => {
|
|
364
|
+
Logger.error("Failed to rebind MPL interactive figure", error);
|
|
365
|
+
});
|
|
366
|
+
}, [modelId, bindModel]);
|
|
311
367
|
|
|
312
368
|
// Re-request figure when tab becomes visible
|
|
313
369
|
useEventListener(document, "visibilitychange", () => {
|
|
@@ -324,6 +380,7 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
324
380
|
export const visibleForTesting = {
|
|
325
381
|
ensureMplJs,
|
|
326
382
|
injectCss,
|
|
383
|
+
MplInteractiveSlot,
|
|
327
384
|
resetMplJsLoading: () => {
|
|
328
385
|
mplJsLoading = null;
|
|
329
386
|
},
|