@marimo-team/islands 0.25.3-dev47 → 0.25.3-dev48
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/{ErrorBoundary-B4QYAM0I.js → ErrorBoundary-DHfwqB0K.js} +1 -1
- package/dist/{code-visibility-BbrgXRMG.js → code-visibility-5llx6KCK.js} +1 -1
- package/dist/{common-DPSFvO_K.js → common-DBC1NAar.js} +89 -61
- package/dist/{data-grid-overlay-editor-D0m9lgVY.js → data-grid-overlay-editor-D01HzlXg.js} +1 -1
- package/dist/{feedback-button-BdqMy9oz.js → feedback-button-gTC25Y-p.js} +2 -2
- package/dist/{glide-data-editor-blkbnr2a.js → glide-data-editor-BXXphavz.js} +3 -3
- package/dist/{globals-CvaBSmzH.js → globals-BcxqBRmX.js} +1 -1
- package/dist/main.js +1491 -1403
- package/dist/{number-overlay-editor-yOSMD6Af.js → number-overlay-editor-CaspWg5p.js} +1 -1
- package/dist/{reveal-component-BHOMrvDj.js → reveal-component-CWk_paD2.js} +2 -2
- package/package.json +1 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +17 -0
- package/src/components/chat/chat-panel.tsx +3 -7
- package/src/components/chat/chat-utils.ts +4 -8
- package/src/components/data-table/__tests__/context-menu-interaction.test.tsx +130 -0
- package/src/components/data-table/context-menu.tsx +36 -20
- package/src/components/editor/cell/CreateCellButton.tsx +8 -10
- package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +54 -0
- package/src/components/editor/cell/code/cell-editor.tsx +1 -1
- package/src/components/editor/cell/code/language-toggle.tsx +8 -7
- package/src/components/editor/file-tree/file-viewer.tsx +4 -2
- package/src/components/editor/output/console/ConsoleOutput.tsx +8 -18
- package/src/components/slides/slide-cell-view.tsx +1 -1
- package/src/core/static/files.ts +2 -0
- package/src/hooks/useInputHistory.ts +4 -7
- package/src/hooks/useResizeObserver.ts +4 -7
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +2 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +241 -2
- package/src/plugins/impl/anywidget/runtime.ts +21 -9
- package/src/plugins/impl/anywidget/widget-binding.ts +160 -82
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +5 -3
- package/src/plugins/impl/panel/PanelPlugin.tsx +15 -16
- package/src/plugins/impl/panel/__tests__/utils.test.ts +39 -1
- package/src/plugins/impl/panel/utils.ts +17 -0
- package/src/stories/__tests__/editor-story.test.tsx +33 -0
- package/src/stories/editor.stories.tsx +24 -17
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
|
|
2
2
|
import { t } from "./react-sHtZdjeP.js";
|
|
3
|
-
import { a as n } from "./glide-data-editor-
|
|
3
|
+
import { a as n } from "./glide-data-editor-BXXphavz.js";
|
|
4
4
|
//#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_27cffe7f1bc30287d59ac27db3240c5b/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
|
|
5
5
|
var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
|
|
6
6
|
name: "NumberOverlayEditorStyle",
|
|
@@ -4,14 +4,14 @@ import { t as o } from "./react-sHtZdjeP.js";
|
|
|
4
4
|
import { t as s } from "./compiler-runtime-CldRdSsv.js";
|
|
5
5
|
import { h as c, t as l } from "./tooltip-BCntm-jf.js";
|
|
6
6
|
import { O as u } from "./error-entries-D3ouOLDe.js";
|
|
7
|
-
import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-
|
|
7
|
+
import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-DBC1NAar.js";
|
|
8
8
|
import { M as C } from "./select-D2iqeJYk.js";
|
|
9
9
|
import { q as ee } from "./dropdown-menu-ugNoZmKx.js";
|
|
10
10
|
import { _ as w } from "./toDate-C0jaysq2.js";
|
|
11
11
|
import { t as T } from "./jsx-runtime-3BD1kT0s.js";
|
|
12
12
|
import { X as E } from "./use-restore-focus-BhXhsNLV.js";
|
|
13
13
|
import { A as D, C as O, b as k } from "./useTheme-BC8Lof0B.js";
|
|
14
|
-
import { a as A, n as te, t as ne } from "./code-visibility-
|
|
14
|
+
import { a as A, n as te, t as ne } from "./code-visibility-5llx6KCK.js";
|
|
15
15
|
/* empty css */
|
|
16
16
|
var j = c("sticky-note", [["path", {
|
|
17
17
|
d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
|
package/package.json
CHANGED
|
@@ -77,6 +77,23 @@ describe("useMessageQueue", () => {
|
|
|
77
77
|
expect(result.current.messages).toEqual([]);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
it("preserves multiple enqueues and flushes before React renders", () => {
|
|
81
|
+
const { result } = renderHook(() => useMessageQueue());
|
|
82
|
+
const send = vi.fn();
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
result.current.enqueue([textPart("first")]);
|
|
86
|
+
result.current.enqueue([textPart("second")]);
|
|
87
|
+
result.current.flushNext(send);
|
|
88
|
+
result.current.flushNext(send);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(send).toHaveBeenNthCalledWith(1, [textPart("first")]);
|
|
92
|
+
expect(send).toHaveBeenNthCalledWith(2, [textPart("second")]);
|
|
93
|
+
expect(result.current.messages).toEqual([]);
|
|
94
|
+
expect(result.current.hasQueuedRef.current).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
80
97
|
it("flushNext on an empty queue is a no-op", () => {
|
|
81
98
|
const { result } = renderHook(() => useMessageQueue());
|
|
82
99
|
const send = vi.fn();
|
|
@@ -716,14 +716,10 @@ const ChatPanelBody = () => {
|
|
|
716
716
|
);
|
|
717
717
|
|
|
718
718
|
const isLoading = status === "submitted" || status === "streaming";
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
// closure's `isLoading` may be stale.
|
|
722
|
-
const isLoadingRef = useRef(isLoading);
|
|
723
|
-
isLoadingRef.current = isLoading;
|
|
724
|
-
|
|
719
|
+
// useEvent reads the latest loading state, even when called after an await
|
|
720
|
+
// (e.g. resolving @-context) that outlives the original render's closure.
|
|
725
721
|
const submitOrQueue = useEvent((parts: ChatMessagePart[]) => {
|
|
726
|
-
if (
|
|
722
|
+
if (isLoading || hasQueuedRef.current) {
|
|
727
723
|
enqueueUserMessage(parts);
|
|
728
724
|
} else {
|
|
729
725
|
sendUserMessage(parts);
|
|
@@ -294,16 +294,12 @@ export function useMessageQueue() {
|
|
|
294
294
|
// invoked from a callback (e.g. `onFinish`) in the same tick as `enqueue`.
|
|
295
295
|
const messagesRef = useRef<QueuedUserMessage[]>([]);
|
|
296
296
|
const hasQueuedRef = useRef(false);
|
|
297
|
-
messagesRef.current = messages;
|
|
298
|
-
hasQueuedRef.current = messages.length > 0;
|
|
299
297
|
|
|
300
298
|
const enqueue = useEvent((parts: ChatMessagePart[]) => {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return next;
|
|
306
|
-
});
|
|
299
|
+
const next = [...messagesRef.current, { id: generateUUID(), parts }];
|
|
300
|
+
messagesRef.current = next;
|
|
301
|
+
hasQueuedRef.current = true;
|
|
302
|
+
setMessages(next);
|
|
307
303
|
});
|
|
308
304
|
|
|
309
305
|
const flushNext = useEvent((send: (parts: ChatMessagePart[]) => void) => {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { type Cell, createTable, getCoreRowModel } from "@tanstack/react-table";
|
|
4
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
5
|
+
import { useRef } from "react";
|
|
6
|
+
import { expect, it, vi } from "vitest";
|
|
7
|
+
import { SetupMocks } from "@/__mocks__/common";
|
|
8
|
+
import { DataTableContextMenu } from "../context-menu";
|
|
9
|
+
|
|
10
|
+
SetupMocks.resizeObserver();
|
|
11
|
+
|
|
12
|
+
const rows = [{ value: "first" }, { value: "second" }];
|
|
13
|
+
const columns = [
|
|
14
|
+
{ accessorKey: "value", meta: { filterType: "text" as const } },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function createCells() {
|
|
18
|
+
const table = createTable({
|
|
19
|
+
data: rows,
|
|
20
|
+
columns,
|
|
21
|
+
locale: "en-US",
|
|
22
|
+
getCoreRowModel: getCoreRowModel(),
|
|
23
|
+
onColumnFiltersChange: () => undefined,
|
|
24
|
+
state: {},
|
|
25
|
+
onStateChange: () => undefined,
|
|
26
|
+
renderFallbackValue: null,
|
|
27
|
+
});
|
|
28
|
+
return table.getRowModel().rows.map((row) => row.getAllCells()[0]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Harness({ cells }: { cells: Cell<(typeof rows)[number], unknown>[] }) {
|
|
32
|
+
const contextMenuRef = useRef<Cell<(typeof rows)[number], unknown> | null>(
|
|
33
|
+
null,
|
|
34
|
+
);
|
|
35
|
+
const tableRef = useRef<HTMLTableSectionElement>(null);
|
|
36
|
+
const tableBody = (
|
|
37
|
+
<tbody ref={tableRef}>
|
|
38
|
+
<tr>
|
|
39
|
+
<td>empty</td>
|
|
40
|
+
</tr>
|
|
41
|
+
{cells.map((cell) => {
|
|
42
|
+
return (
|
|
43
|
+
<tr key={cell.id}>
|
|
44
|
+
<td
|
|
45
|
+
data-cell-id={cell.id}
|
|
46
|
+
onContextMenu={() => {
|
|
47
|
+
contextMenuRef.current = cell;
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{String(cell.getValue())}
|
|
51
|
+
</td>
|
|
52
|
+
</tr>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</tbody>
|
|
56
|
+
);
|
|
57
|
+
return (
|
|
58
|
+
<table>
|
|
59
|
+
<DataTableContextMenu
|
|
60
|
+
contextMenuRef={contextMenuRef}
|
|
61
|
+
tableRef={tableRef}
|
|
62
|
+
tableBody={tableBody}
|
|
63
|
+
copyAllCells={() => undefined}
|
|
64
|
+
/>
|
|
65
|
+
</table>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it("filters the latest right-clicked cell when the menu is already open", () => {
|
|
70
|
+
const onFilter = vi.fn();
|
|
71
|
+
const table = createTable({
|
|
72
|
+
data: rows,
|
|
73
|
+
columns,
|
|
74
|
+
locale: "en-US",
|
|
75
|
+
getCoreRowModel: getCoreRowModel(),
|
|
76
|
+
onColumnFiltersChange: onFilter,
|
|
77
|
+
state: {},
|
|
78
|
+
onStateChange: () => undefined,
|
|
79
|
+
renderFallbackValue: null,
|
|
80
|
+
});
|
|
81
|
+
render(
|
|
82
|
+
<Harness
|
|
83
|
+
cells={table.getRowModel().rows.map((row) => row.getAllCells()[0])}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const firstCell = screen.getByText("first");
|
|
88
|
+
const secondCell = screen.getByText("second");
|
|
89
|
+
fireEvent.contextMenu(firstCell);
|
|
90
|
+
expect(firstCell).toHaveClass("bg-(--green-4)");
|
|
91
|
+
expect(
|
|
92
|
+
screen.getByRole("menuitem", { name: "Filter by this value" }),
|
|
93
|
+
).toBeInTheDocument();
|
|
94
|
+
fireEvent.contextMenu(secondCell);
|
|
95
|
+
expect(firstCell).not.toHaveClass("bg-(--green-4)");
|
|
96
|
+
expect(secondCell).toHaveClass("bg-(--green-4)");
|
|
97
|
+
fireEvent.click(
|
|
98
|
+
screen.getByRole("menuitem", { name: "Filter by this value" }),
|
|
99
|
+
);
|
|
100
|
+
expect(firstCell).not.toHaveClass("bg-(--green-4)");
|
|
101
|
+
expect(secondCell).not.toHaveClass("bg-(--green-4)");
|
|
102
|
+
|
|
103
|
+
const update = onFilter.mock.calls.at(-1)?.[0];
|
|
104
|
+
expect(update([])).toEqual([
|
|
105
|
+
{
|
|
106
|
+
id: "value",
|
|
107
|
+
value: { type: "text", operator: "equals", text: "second" },
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not reuse a previous cell after the menu closes", () => {
|
|
113
|
+
render(<Harness cells={createCells()} />);
|
|
114
|
+
|
|
115
|
+
const firstCell = screen.getByText("first");
|
|
116
|
+
fireEvent.contextMenu(firstCell);
|
|
117
|
+
expect(firstCell).toHaveClass("bg-(--green-4)");
|
|
118
|
+
fireEvent.keyDown(document.activeElement ?? document.body, {
|
|
119
|
+
key: "Escape",
|
|
120
|
+
});
|
|
121
|
+
expect(firstCell).not.toHaveClass("bg-(--green-4)");
|
|
122
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
123
|
+
|
|
124
|
+
// Right-clicking outside any data cell must not resurrect the old one
|
|
125
|
+
fireEvent.contextMenu(screen.getByText("empty"));
|
|
126
|
+
expect(firstCell).not.toHaveClass("bg-(--green-4)");
|
|
127
|
+
expect(
|
|
128
|
+
screen.queryByRole("menuitem", { name: "Filter by this value" }),
|
|
129
|
+
).not.toBeInTheDocument();
|
|
130
|
+
});
|
|
@@ -12,7 +12,7 @@ import { Logger } from "@/utils/Logger";
|
|
|
12
12
|
import type { Cell } from "@tanstack/react-table";
|
|
13
13
|
import { useAtomValue } from "jotai";
|
|
14
14
|
import { CopyIcon, FilterIcon, SquareStack } from "lucide-react";
|
|
15
|
-
import type
|
|
15
|
+
import { type RefObject, useRef, useState } from "react";
|
|
16
16
|
import useEvent from "react-use-event-hook";
|
|
17
17
|
import {
|
|
18
18
|
ContextMenu,
|
|
@@ -123,8 +123,21 @@ export const DataTableContextMenu = <TData,>({
|
|
|
123
123
|
tableRef: RefObject<HTMLTableSectionElement | null>;
|
|
124
124
|
copyAllCells: () => void;
|
|
125
125
|
}) => {
|
|
126
|
+
const [activeCell, setActiveCell] = useState<Cell<TData, unknown> | null>(
|
|
127
|
+
null,
|
|
128
|
+
);
|
|
129
|
+
const highlightedCell = useRef<Element | null>(null);
|
|
126
130
|
const handleContextMenuChange = useEvent((open: boolean) => {
|
|
131
|
+
highlightedCell.current?.classList.remove("bg-(--green-4)");
|
|
132
|
+
highlightedCell.current = null;
|
|
133
|
+
if (!open) {
|
|
134
|
+
// Forget the cell so a later right-click outside any cell can't reuse it
|
|
135
|
+
contextMenuRef.current = null;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
const cell = contextMenuRef.current;
|
|
140
|
+
setActiveCell(cell);
|
|
128
141
|
if (!cell) {
|
|
129
142
|
return;
|
|
130
143
|
}
|
|
@@ -138,42 +151,45 @@ export const DataTableContextMenu = <TData,>({
|
|
|
138
151
|
return;
|
|
139
152
|
}
|
|
140
153
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
} else {
|
|
144
|
-
cellElement.classList.remove("bg-(--green-4)");
|
|
145
|
-
}
|
|
154
|
+
cellElement.classList.add("bg-(--green-4)");
|
|
155
|
+
highlightedCell.current = cellElement;
|
|
146
156
|
});
|
|
147
157
|
|
|
148
158
|
return (
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
// Open via the trigger so a right-click while already open retargets the
|
|
160
|
+
// menu; Radix only reports open changes, so use it for closing.
|
|
161
|
+
<ContextMenu
|
|
162
|
+
onOpenChange={(open) => {
|
|
163
|
+
if (!open) {
|
|
164
|
+
handleContextMenuChange(false);
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<ContextMenuTrigger
|
|
169
|
+
asChild={true}
|
|
170
|
+
onContextMenu={() => handleContextMenuChange(true)}
|
|
171
|
+
>
|
|
172
|
+
{tableBody}
|
|
173
|
+
</ContextMenuTrigger>
|
|
151
174
|
<ContextMenuPortal>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
/>
|
|
175
|
+
{activeCell && (
|
|
176
|
+
<CellContextMenu cell={activeCell} copySelectedCells={copyAllCells} />
|
|
177
|
+
)}
|
|
156
178
|
</ContextMenuPortal>
|
|
157
179
|
</ContextMenu>
|
|
158
180
|
);
|
|
159
181
|
};
|
|
160
182
|
|
|
161
183
|
export const CellContextMenu = <TData,>({
|
|
162
|
-
|
|
184
|
+
cell,
|
|
163
185
|
copySelectedCells,
|
|
164
186
|
}: {
|
|
165
|
-
|
|
187
|
+
cell: Cell<TData, unknown>;
|
|
166
188
|
copySelectedCells: () => void;
|
|
167
189
|
}) => {
|
|
168
190
|
const selectedCells = useAtomValue(selectedCellsAtom);
|
|
169
191
|
const multipleSelectedCells = selectedCells.size > 1;
|
|
170
192
|
|
|
171
|
-
const cell = cellRef.current;
|
|
172
|
-
if (!cell) {
|
|
173
|
-
Logger.error("No cell found in context menu");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
193
|
const table = cell.getContext().table;
|
|
178
194
|
const displayedValue = cell.getValue();
|
|
179
195
|
const rawValue =
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { DatabaseIcon, DiamondPlusIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
4
|
import { Button } from "@/components/editor/inputs/Inputs";
|
|
5
5
|
import { MinimalHotkeys } from "@/components/shortcuts/renderShortcut";
|
|
6
6
|
import {
|
|
@@ -36,7 +36,8 @@ export const CreateCellButton = ({
|
|
|
36
36
|
const { createNewCell, addSetupCellIfDoesntExist } = useCellActions();
|
|
37
37
|
const shortcut = `${oneClickShortcut}-Click`;
|
|
38
38
|
const [open, setOpen] = useState(false);
|
|
39
|
-
|
|
39
|
+
// Time the dropdown last opened, to ignore the click that opened it
|
|
40
|
+
const openedAt = useRef(0);
|
|
40
41
|
|
|
41
42
|
const baseTooltipContent =
|
|
42
43
|
getConnectionTooltip(connectionState) || tooltipContent;
|
|
@@ -83,9 +84,7 @@ export const CreateCellButton = ({
|
|
|
83
84
|
|
|
84
85
|
const openDropdown = () => {
|
|
85
86
|
setOpen(true);
|
|
86
|
-
|
|
87
|
-
// Allow interactions after a brief delay to prevent the dropdown items immediately being clicked
|
|
88
|
-
setTimeout(() => setJustOpened(false), 200);
|
|
87
|
+
openedAt.current = Date.now();
|
|
89
88
|
};
|
|
90
89
|
|
|
91
90
|
// We use onPointerDownCapture (not onPointerDown) to intercept events in
|
|
@@ -121,15 +120,14 @@ export const CreateCellButton = ({
|
|
|
121
120
|
const handleOpenChange = (isOpen: boolean) => {
|
|
122
121
|
setOpen(isOpen);
|
|
123
122
|
if (isOpen) {
|
|
124
|
-
|
|
125
|
-
// Allow interactions after a brief delay
|
|
126
|
-
setTimeout(() => setJustOpened(false), 200);
|
|
123
|
+
openedAt.current = Date.now();
|
|
127
124
|
}
|
|
128
125
|
};
|
|
129
126
|
|
|
130
127
|
const handleFirstItemClick = (e: React.MouseEvent) => {
|
|
131
|
-
// Hack to prevent the first item from being clicked when the dropdown is
|
|
132
|
-
|
|
128
|
+
// Hack to prevent the first item from being clicked when the dropdown is
|
|
129
|
+
// opened: allow interactions only after a brief delay
|
|
130
|
+
if (Date.now() - openedAt.current < 200) {
|
|
133
131
|
e.preventDefault();
|
|
134
132
|
e.stopPropagation();
|
|
135
133
|
return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { EditorState } from "@codemirror/state";
|
|
4
|
+
import { EditorView } from "@codemirror/view";
|
|
5
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
6
|
+
import { createRef } from "react";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { switchLanguage } from "@/core/codemirror/language/extension";
|
|
10
|
+
import { LanguageToggle } from "../language-toggle";
|
|
11
|
+
|
|
12
|
+
vi.mock("@/core/codemirror/language/extension", () => ({
|
|
13
|
+
switchLanguage: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("LanguageToggle", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.mocked(switchLanguage).mockClear();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses the editor mounted after the button rendered", () => {
|
|
22
|
+
const editorViewRef = createRef<EditorView>();
|
|
23
|
+
const onAfterToggle = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<TooltipProvider>
|
|
26
|
+
<LanguageToggle
|
|
27
|
+
editorViewRef={editorViewRef}
|
|
28
|
+
currentLanguageAdapter="python"
|
|
29
|
+
canSwitchToLanguage={true}
|
|
30
|
+
toType="markdown"
|
|
31
|
+
displayName="Markdown"
|
|
32
|
+
icon="Markdown"
|
|
33
|
+
onAfterToggle={onAfterToggle}
|
|
34
|
+
/>
|
|
35
|
+
</TooltipProvider>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Markdown" }));
|
|
39
|
+
expect(switchLanguage).not.toHaveBeenCalled();
|
|
40
|
+
expect(onAfterToggle).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
const view = new EditorView({ state: EditorState.create() });
|
|
43
|
+
editorViewRef.current = view;
|
|
44
|
+
try {
|
|
45
|
+
fireEvent.click(screen.getByRole("button", { name: "Markdown" }));
|
|
46
|
+
expect(switchLanguage).toHaveBeenCalledWith(view, {
|
|
47
|
+
language: "markdown",
|
|
48
|
+
});
|
|
49
|
+
expect(onAfterToggle).toHaveBeenCalledOnce();
|
|
50
|
+
} finally {
|
|
51
|
+
view.destroy();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -572,7 +572,7 @@ const CellEditorInternal = ({
|
|
|
572
572
|
<div className="absolute top-1 right-5">
|
|
573
573
|
<LanguageToggles
|
|
574
574
|
code={code}
|
|
575
|
-
|
|
575
|
+
editorViewRef={editorViewRef}
|
|
576
576
|
currentLanguageAdapter={languageAdapter}
|
|
577
577
|
onAfterToggle={afterToggleMarkdown}
|
|
578
578
|
/>
|
|
@@ -13,7 +13,7 @@ import { Functions } from "@/utils/functions";
|
|
|
13
13
|
import { MarkdownIcon, PythonIcon } from "./icons";
|
|
14
14
|
|
|
15
15
|
interface LanguageTogglesProps {
|
|
16
|
-
|
|
16
|
+
editorViewRef: React.RefObject<EditorView | null>;
|
|
17
17
|
code: string;
|
|
18
18
|
currentLanguageAdapter: LanguageAdapter["type"] | undefined;
|
|
19
19
|
onAfterToggle: () => void;
|
|
@@ -25,7 +25,7 @@ interface LanguageTogglesProps {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
28
|
-
|
|
28
|
+
editorViewRef,
|
|
29
29
|
code,
|
|
30
30
|
currentLanguageAdapter,
|
|
31
31
|
onAfterToggle,
|
|
@@ -43,7 +43,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
43
43
|
return (
|
|
44
44
|
<div className={className}>
|
|
45
45
|
<LanguageToggle
|
|
46
|
-
|
|
46
|
+
editorViewRef={editorViewRef}
|
|
47
47
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
48
48
|
canSwitchToLanguage={canUseSQL && currentLanguageAdapter === "python"}
|
|
49
49
|
icon={
|
|
@@ -58,7 +58,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
58
58
|
onAfterToggle={onAfterToggle}
|
|
59
59
|
/>
|
|
60
60
|
<LanguageToggle
|
|
61
|
-
|
|
61
|
+
editorViewRef={editorViewRef}
|
|
62
62
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
63
63
|
canSwitchToLanguage={
|
|
64
64
|
canUseMarkdown && currentLanguageAdapter === "python"
|
|
@@ -75,7 +75,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
75
75
|
onAfterToggle={onAfterToggle}
|
|
76
76
|
/>
|
|
77
77
|
<LanguageToggle
|
|
78
|
-
|
|
78
|
+
editorViewRef={editorViewRef}
|
|
79
79
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
80
80
|
canSwitchToLanguage={true}
|
|
81
81
|
icon={
|
|
@@ -95,7 +95,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
95
95
|
|
|
96
96
|
interface Props {
|
|
97
97
|
className?: string;
|
|
98
|
-
|
|
98
|
+
editorViewRef: React.RefObject<EditorView | null>;
|
|
99
99
|
canSwitchToLanguage: boolean;
|
|
100
100
|
currentLanguageAdapter: LanguageAdapter["type"] | undefined;
|
|
101
101
|
toType: LanguageAdapter["type"];
|
|
@@ -105,7 +105,7 @@ interface Props {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export const LanguageToggle: React.FC<Props> = ({
|
|
108
|
-
|
|
108
|
+
editorViewRef,
|
|
109
109
|
currentLanguageAdapter,
|
|
110
110
|
canSwitchToLanguage,
|
|
111
111
|
icon,
|
|
@@ -114,6 +114,7 @@ export const LanguageToggle: React.FC<Props> = ({
|
|
|
114
114
|
onAfterToggle,
|
|
115
115
|
}) => {
|
|
116
116
|
const handleClick = () => {
|
|
117
|
+
const editorView = editorViewRef.current;
|
|
117
118
|
if (!editorView) {
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
SaveIcon,
|
|
10
10
|
} from "lucide-react";
|
|
11
11
|
import type React from "react";
|
|
12
|
-
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
13
13
|
import { renderShortcut } from "@/components/shortcuts/renderShortcut";
|
|
14
14
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
15
15
|
import { Button } from "@/components/ui/button";
|
|
@@ -82,7 +82,9 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
|
|
|
82
82
|
// carry no draft to preserve.
|
|
83
83
|
// We use a ref for internalValue so we don't call this effect on each keystroke
|
|
84
84
|
const internalValueRef = useRef<string>(internalValue);
|
|
85
|
-
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
internalValueRef.current = internalValue;
|
|
87
|
+
}, [internalValue]);
|
|
86
88
|
useEffect(() => {
|
|
87
89
|
return () => {
|
|
88
90
|
if (
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
ChevronsUpDownIcon,
|
|
7
7
|
WrapTextIcon,
|
|
8
8
|
} from "lucide-react";
|
|
9
|
-
import React, { useEffect, useLayoutEffect,
|
|
9
|
+
import React, { useEffect, useLayoutEffect, useState } from "react";
|
|
10
10
|
import { ToggleButton } from "react-aria-components";
|
|
11
11
|
import { DebuggerControls } from "@/components/debugger/debugger-code";
|
|
12
12
|
import { CopyClipboardIcon } from "@/components/icons/copy-icon";
|
|
@@ -49,31 +49,21 @@ export const CONSOLE_CLEAR_DEBOUNCE_MS = 200;
|
|
|
49
49
|
*/
|
|
50
50
|
function useDebouncedConsoleOutputs<T>(outputs: T[]): T[] {
|
|
51
51
|
const [debouncedOutputs, setDebouncedOutputs] = useState(outputs);
|
|
52
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
52
|
|
|
54
|
-
// Non-empty outputs: apply immediately
|
|
53
|
+
// Non-empty outputs: apply immediately. Effect cleanup cancels pending clears.
|
|
55
54
|
if (outputs.length > 0 && debouncedOutputs !== outputs) {
|
|
56
|
-
if (timerRef.current !== null) {
|
|
57
|
-
clearTimeout(timerRef.current);
|
|
58
|
-
timerRef.current = null;
|
|
59
|
-
}
|
|
60
55
|
setDebouncedOutputs(outputs);
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
// Empty outputs: delay the clear so new outputs can arrive first
|
|
64
59
|
useEffect(() => {
|
|
65
|
-
if (outputs.length
|
|
66
|
-
|
|
67
|
-
timerRef.current = null;
|
|
68
|
-
setDebouncedOutputs([]);
|
|
69
|
-
}, CONSOLE_CLEAR_DEBOUNCE_MS);
|
|
60
|
+
if (outputs.length > 0) {
|
|
61
|
+
return;
|
|
70
62
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
};
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
setDebouncedOutputs([]);
|
|
65
|
+
}, CONSOLE_CLEAR_DEBOUNCE_MS);
|
|
66
|
+
return () => clearTimeout(timer);
|
|
77
67
|
}, [outputs]);
|
|
78
68
|
|
|
79
69
|
return debouncedOutputs;
|
|
@@ -117,7 +117,7 @@ export const SlideCellView = ({ cell }: { cell: RuntimeCell }) => {
|
|
|
117
117
|
/>
|
|
118
118
|
<LanguageToggles
|
|
119
119
|
code={cell.code}
|
|
120
|
-
|
|
120
|
+
editorViewRef={editorViewRef}
|
|
121
121
|
currentLanguageAdapter={languageAdapter}
|
|
122
122
|
onAfterToggle={afterToggleLanguage}
|
|
123
123
|
className="flex items-center gap-1"
|
package/src/core/static/files.ts
CHANGED
|
@@ -125,6 +125,8 @@ function withoutLeadingDot(path: string): string {
|
|
|
125
125
|
/**
|
|
126
126
|
* Resolve a URL to a blob URL if it's a virtual file, for use with dynamic import().
|
|
127
127
|
* Unlike fetch, import() can't be patched, so we need to convert data URLs to blob URLs.
|
|
128
|
+
* The caller owns a returned URL that differs from the input and must revoke it
|
|
129
|
+
* once no module consumers or their cleanup callbacks need it.
|
|
128
130
|
*
|
|
129
131
|
* @returns The original URL if not a virtual file, or a blob URL if it is
|
|
130
132
|
*/
|
|
@@ -37,12 +37,9 @@ export function useInputHistory({
|
|
|
37
37
|
const historyIndexRef = React.useRef<number>(-1);
|
|
38
38
|
// Store the current input when navigating history
|
|
39
39
|
const pendingInputRef = React.useRef<string>("");
|
|
40
|
-
// Keep a ref to history for use in callbacks without causing re-renders
|
|
41
|
-
const historyRef = React.useRef(history);
|
|
42
|
-
historyRef.current = history;
|
|
43
40
|
|
|
44
41
|
const navigateUp = React.useCallback(() => {
|
|
45
|
-
const currentHistory =
|
|
42
|
+
const currentHistory = history;
|
|
46
43
|
if (currentHistory.length === 0) {
|
|
47
44
|
return;
|
|
48
45
|
}
|
|
@@ -59,10 +56,10 @@ export function useInputHistory({
|
|
|
59
56
|
historyIndexRef.current = newIndex;
|
|
60
57
|
setValue(currentHistory[currentHistory.length - 1 - newIndex]);
|
|
61
58
|
}
|
|
62
|
-
}, [value, setValue]);
|
|
59
|
+
}, [history, value, setValue]);
|
|
63
60
|
|
|
64
61
|
const navigateDown = React.useCallback(() => {
|
|
65
|
-
const currentHistory =
|
|
62
|
+
const currentHistory = history;
|
|
66
63
|
if (currentHistory.length === 0) {
|
|
67
64
|
return;
|
|
68
65
|
}
|
|
@@ -79,7 +76,7 @@ export function useInputHistory({
|
|
|
79
76
|
historyIndexRef.current = -1;
|
|
80
77
|
setValue(pendingInputRef.current);
|
|
81
78
|
}
|
|
82
|
-
}, [setValue]);
|
|
79
|
+
}, [history, setValue]);
|
|
83
80
|
|
|
84
81
|
const addToHistory = React.useCallback((command: string) => {
|
|
85
82
|
setHistory((prev) => {
|