@marimo-team/islands 0.25.3-dev129 → 0.25.3-dev134
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-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
- package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
- package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
- package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
- package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
- package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
- package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
- package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
- package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
- package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
- package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
- package/dist/main.js +5504 -5477
- package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
- package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
- package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/module-entry.test.ts +32 -0
- package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
- package/src/components/ai/ai-model-dropdown.tsx +11 -6
- package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
- package/src/components/chat/acp/blocks.tsx +1 -0
- package/src/components/chat/acp/session-tabs.tsx +8 -3
- package/src/components/data-table/TableTopBar.tsx +2 -0
- package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
- package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
- package/src/components/data-table/data-table.tsx +6 -0
- package/src/components/data-table/range-focus/atoms.ts +4 -6
- package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
- package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
- package/src/components/data-table/renderers.tsx +16 -7
- package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
- package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
- package/src/components/datasources/datasources.tsx +1 -0
- package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
- package/src/components/editor/cell/collapse.tsx +5 -3
- package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
- package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
- package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
- package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
- package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
- package/src/components/editor/file-tree/file-explorer.tsx +35 -3
- package/src/components/editor/file-tree/file-name-input.tsx +1 -0
- package/src/components/editor/header/status.tsx +28 -5
- package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
- package/src/components/editor/links/cell-link.tsx +14 -5
- package/src/components/editor/navigation/focus-utils.ts +4 -4
- package/src/components/editor/notebook-cell.tsx +11 -2
- package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
- package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
- package/src/components/editor/package-alert.tsx +8 -3
- package/src/components/scratchpad/scratchpad.test.tsx +95 -0
- package/src/components/scratchpad/scratchpad.tsx +14 -1
- package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
- package/src/components/tracing/tracing.tsx +43 -39
- package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
- package/src/components/ui/draggable-popover.tsx +24 -2
- package/src/core/attachments/state.ts +2 -2
- package/src/core/document/__tests__/codemirror.test.ts +3 -1
- package/src/core/document/__tests__/hooks.test.tsx +7 -7
- package/src/core/document/__tests__/model.test.ts +44 -11
- package/src/core/document/__tests__/scenario-fixture.ts +2 -1
- package/src/core/document/__tests__/test-notebook.ts +19 -9
- package/src/core/document/draft.ts +1 -1
- package/src/core/document/index.ts +3 -10
- package/src/core/document/model.ts +14 -11
- package/src/core/document/state.ts +1 -1
- package/src/core/document/sync.ts +24 -18
- package/src/core/host/AGENTS.md +3 -7
- package/src/core/host/__tests__/fixtures.ts +30 -0
- package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
- package/src/core/host/__tests__/wasm.test.ts +8 -0
- package/src/core/host/backend.ts +4 -0
- package/src/core/host/index.ts +14 -0
- package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
- package/src/core/host/static.ts +2 -0
- package/src/core/host/wasm.ts +10 -1
- package/src/core/wasm/bridge.ts +1 -1
- package/src/core/websocket/__tests__/backend.test.ts +111 -22
- package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
- package/src/core/websocket/backend.ts +43 -5
- package/src/core/websocket/connection-state.ts +1 -1
- package/src/core/websocket/state.ts +3 -3
- package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
- package/src/core/websocket/types.ts +2 -2
- package/src/core/websocket/useNotebookReplica.ts +69 -0
- package/src/core/websocket/useNotebookSession.tsx +6 -6
- package/src/plugins/impl/chat/chat-ui.tsx +2 -1
- package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
- package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
- package/src/plugins/impl/data-frames/panel.tsx +43 -16
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
- package/src/core/websocket/useNotebookClient.ts +0 -89
|
@@ -124,6 +124,7 @@ export const DataTableBody = <TData,>({
|
|
|
124
124
|
handleCellMouseUp,
|
|
125
125
|
handleCellMouseOver,
|
|
126
126
|
handleCellsKeyDown,
|
|
127
|
+
handleCellFocus,
|
|
127
128
|
handleCopy: handleCopyAllCells,
|
|
128
129
|
} = useCellRangeSelection({ table });
|
|
129
130
|
|
|
@@ -147,10 +148,16 @@ export const DataTableBody = <TData,>({
|
|
|
147
148
|
return (
|
|
148
149
|
<TableCell
|
|
149
150
|
tabIndex={0}
|
|
151
|
+
role={table.options.enableCellSelection ? "gridcell" : undefined}
|
|
152
|
+
aria-selected={
|
|
153
|
+
table.options.enableCellSelection
|
|
154
|
+
? cell.getIsSelected?.()
|
|
155
|
+
: undefined
|
|
156
|
+
}
|
|
150
157
|
{...getCellDomProps(cell.id)}
|
|
151
158
|
key={cell.id}
|
|
152
159
|
className={cn(
|
|
153
|
-
"whitespace-pre truncate outline-hidden border-r border-r-border/75",
|
|
160
|
+
"whitespace-pre truncate outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring border-r border-r-border/75",
|
|
154
161
|
!fixedWidth && "max-w-[300px]",
|
|
155
162
|
cell.column.getColumnWrapping &&
|
|
156
163
|
cell.column.getColumnWrapping?.() === "wrap" &&
|
|
@@ -169,7 +176,13 @@ export const DataTableBody = <TData,>({
|
|
|
169
176
|
hoverTooltip.handleCellMouseOver(e, cell);
|
|
170
177
|
}}
|
|
171
178
|
onMouseLeave={hoverTooltip.handleCellMouseLeave}
|
|
172
|
-
onFocus={(e) =>
|
|
179
|
+
onFocus={(e) => {
|
|
180
|
+
if (e.target === e.currentTarget) {
|
|
181
|
+
handleCellFocus(cell);
|
|
182
|
+
}
|
|
183
|
+
hoverTooltip.handleCellFocus(e, cell);
|
|
184
|
+
}}
|
|
185
|
+
onKeyDown={(e) => handleCellsKeyDown(e, cell)}
|
|
173
186
|
onBlur={hoverTooltip.handleCellBlur}
|
|
174
187
|
onContextMenu={() => handleContextMenu(cell)}
|
|
175
188
|
>
|
|
@@ -264,11 +277,7 @@ export const DataTableBody = <TData,>({
|
|
|
264
277
|
return rows.map((row) => renderRow(row));
|
|
265
278
|
};
|
|
266
279
|
|
|
267
|
-
const tableBody = (
|
|
268
|
-
<TableBody onKeyDown={handleCellsKeyDown} ref={tableRef}>
|
|
269
|
-
{renderRows()}
|
|
270
|
-
</TableBody>
|
|
271
|
-
);
|
|
280
|
+
const tableBody = <TableBody ref={tableRef}>{renderRows()}</TableBody>;
|
|
272
281
|
|
|
273
282
|
return (
|
|
274
283
|
<>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
4
|
import { Provider } from "jotai";
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
@@ -17,6 +17,29 @@ const renderWithProviders = (component: React.ReactNode) => {
|
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
describe("RowExpandedPanel", () => {
|
|
20
|
+
it("preserves Space activation on navigation buttons", async () => {
|
|
21
|
+
const select = vi.fn();
|
|
22
|
+
const navigate = vi.fn();
|
|
23
|
+
renderWithProviders(
|
|
24
|
+
<RowViewerPanel
|
|
25
|
+
rowIdx={0}
|
|
26
|
+
setRowIdx={navigate}
|
|
27
|
+
totalRows={2}
|
|
28
|
+
fieldTypes={[["name", ["string", "string"]]]}
|
|
29
|
+
getRow={async () => ({ rows: [{ name: "Alice" }] })}
|
|
30
|
+
isSelectable={true}
|
|
31
|
+
isRowSelected={false}
|
|
32
|
+
handleRowSelectionChange={select}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
await screen.findByText("Alice");
|
|
36
|
+
const next = screen.getByRole("button", { name: "Next row" });
|
|
37
|
+
expect(fireEvent.keyDown(next, { key: " " })).toBe(true);
|
|
38
|
+
expect(select).not.toHaveBeenCalled();
|
|
39
|
+
fireEvent.click(next);
|
|
40
|
+
expect(navigate).toHaveBeenCalledExactlyOnceWith(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
20
43
|
const mockFieldTypes: FieldTypesWithExternalType = [
|
|
21
44
|
["name", ["string", "string"]],
|
|
22
45
|
["age", ["number", "number"]],
|
|
@@ -37,6 +37,7 @@ import type { GetRowResult } from "@/plugins/impl/DataTablePlugin";
|
|
|
37
37
|
import { Objects } from "@/utils/objects";
|
|
38
38
|
import { NAMELESS_COLUMN_PREFIX, renderCellValue } from "../columns";
|
|
39
39
|
import { prettifyRowCount } from "../pagination";
|
|
40
|
+
import { isInteractiveTarget } from "../range-focus/use-cell-range-selection";
|
|
40
41
|
import {
|
|
41
42
|
type FieldTypesWithExternalType,
|
|
42
43
|
INDEX_COLUMN_NAME,
|
|
@@ -102,8 +103,7 @@ export const RowViewerPanel: React.FC<RowViewerPanelProps> = ({
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
105
|
-
|
|
106
|
-
if (e.target instanceof HTMLInputElement) {
|
|
106
|
+
if (e.defaultPrevented || isInteractiveTarget(e)) {
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
switch (e.key) {
|
|
@@ -284,6 +284,7 @@ export const DataSources: React.FC = () => {
|
|
|
284
284
|
{hasSearch && (
|
|
285
285
|
<button
|
|
286
286
|
type="button"
|
|
287
|
+
aria-label="Clear table search"
|
|
287
288
|
className="float-right border-b px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
|
|
288
289
|
onClick={() => setSearchValue("")}
|
|
289
290
|
>
|
|
@@ -27,10 +27,12 @@ function mountPackageAlert({
|
|
|
27
27
|
configuredManager,
|
|
28
28
|
sandboxBackend,
|
|
29
29
|
source = "kernel",
|
|
30
|
+
packages = ["polars"],
|
|
30
31
|
}: {
|
|
31
32
|
configuredManager: PackageManagerName;
|
|
32
33
|
sandboxBackend: "uv" | "pixi" | null;
|
|
33
34
|
source?: "kernel" | "server";
|
|
35
|
+
packages?: string[];
|
|
34
36
|
}) {
|
|
35
37
|
const store = createStore();
|
|
36
38
|
const client = MockRequestClient.create();
|
|
@@ -55,7 +57,7 @@ function mountPackageAlert({
|
|
|
55
57
|
packageAlert: {
|
|
56
58
|
id: "missing-package",
|
|
57
59
|
kind: "missing",
|
|
58
|
-
packages
|
|
60
|
+
packages,
|
|
59
61
|
isolated: true,
|
|
60
62
|
source,
|
|
61
63
|
},
|
|
@@ -97,6 +99,25 @@ it("keeps the configured package manager selectable outside a sandbox", () => {
|
|
|
97
99
|
expect(managerSelect).not.toBeDisabled();
|
|
98
100
|
});
|
|
99
101
|
|
|
102
|
+
it("removes an extra absent from available choices through a keyboard menu item", async () => {
|
|
103
|
+
const client = mountPackageAlert({
|
|
104
|
+
configuredManager: "uv",
|
|
105
|
+
sandboxBackend: null,
|
|
106
|
+
packages: ["polars[dev]"],
|
|
107
|
+
});
|
|
108
|
+
fireEvent.keyDown(screen.getByRole("button", { name: "dev" }), {
|
|
109
|
+
key: "ArrowDown",
|
|
110
|
+
});
|
|
111
|
+
const remove = await screen.findByRole("menuitem", { name: "Remove dev" });
|
|
112
|
+
fireEvent.keyDown(remove, { key: "Enter" });
|
|
113
|
+
fireEvent.click(screen.getByTestId("install-packages-button"));
|
|
114
|
+
expect(client.sendInstallMissingPackages).toHaveBeenCalledExactlyOnceWith({
|
|
115
|
+
manager: "uv",
|
|
116
|
+
source: "kernel",
|
|
117
|
+
versions: { polars: "" },
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
100
121
|
it("installs a selected version in a Pixi sandbox", () => {
|
|
101
122
|
const client = mountPackageAlert({
|
|
102
123
|
configuredManager: "uv",
|
|
@@ -67,10 +67,12 @@ export const CollapsedCellBanner: React.FC<{
|
|
|
67
67
|
const states = getDescendantsStatus(notebook, cellId);
|
|
68
68
|
|
|
69
69
|
return (
|
|
70
|
-
<
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
aria-label={`Expand ${count} collapsed ${count === 1 ? "cell" : "cells"}`}
|
|
71
73
|
onClick={onClick}
|
|
72
74
|
className={cn(
|
|
73
|
-
"flex items-center justify-between w-[calc(100%-2rem)] h-9 bg-muted rounded-b mx-4 opacity-80 hover:opacity-100 cursor-pointer",
|
|
75
|
+
"flex items-center justify-between w-[calc(100%-2rem)] h-9 bg-muted rounded-b mx-4 opacity-80 hover:opacity-100 cursor-pointer focus-visible:outline focus-visible:outline-ring",
|
|
74
76
|
)}
|
|
75
77
|
>
|
|
76
78
|
<Toolbar
|
|
@@ -102,7 +104,7 @@ export const CollapsedCellBanner: React.FC<{
|
|
|
102
104
|
</>
|
|
103
105
|
}
|
|
104
106
|
/>
|
|
105
|
-
</
|
|
107
|
+
</button>
|
|
106
108
|
);
|
|
107
109
|
});
|
|
108
110
|
CollapsedCellBanner.displayName = "CollapsedCellBanner";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { OutlineItem } from "@/core/cells/outline";
|
|
6
|
+
import { MiniMap, OutlineList } from "../floating-outline";
|
|
7
|
+
import { scrollToOutlineItem } from "../useActiveOutline";
|
|
8
|
+
|
|
9
|
+
vi.mock("../useActiveOutline", () => ({ scrollToOutlineItem: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
const items: OutlineItem[] = [
|
|
12
|
+
{ name: "First", level: 1, by: { id: "first" } },
|
|
13
|
+
{ name: "Second", html: "<em>Second</em>", level: 2, by: { id: "second" } },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe("outline keyboard navigation", () => {
|
|
17
|
+
it.each([
|
|
18
|
+
{ Component: MiniMap, role: "button" },
|
|
19
|
+
{ Component: OutlineList, role: "link" },
|
|
20
|
+
])(
|
|
21
|
+
"keeps the focused $role when other headings change",
|
|
22
|
+
({ Component, role }) => {
|
|
23
|
+
const duplicate: OutlineItem = {
|
|
24
|
+
name: "Repeated second",
|
|
25
|
+
level: 2,
|
|
26
|
+
by: { id: "second" },
|
|
27
|
+
};
|
|
28
|
+
const initial = [...items, duplicate];
|
|
29
|
+
const props = {
|
|
30
|
+
activeHeaderId: undefined,
|
|
31
|
+
activeOccurrences: undefined,
|
|
32
|
+
};
|
|
33
|
+
const { rerender } = render(<Component {...props} items={initial} />);
|
|
34
|
+
const focused = screen.getByRole(role, { name: "Repeated second" });
|
|
35
|
+
focused.focus();
|
|
36
|
+
|
|
37
|
+
for (const by of [{ id: "inserted" }, { path: "second" }]) {
|
|
38
|
+
const inserted: OutlineItem = { name: "Inserted", level: 1, by };
|
|
39
|
+
rerender(<Component {...props} items={[inserted, ...initial]} />);
|
|
40
|
+
expect(screen.getByRole(role, { name: "Repeated second" })).toBe(
|
|
41
|
+
focused,
|
|
42
|
+
);
|
|
43
|
+
expect(focused).toHaveFocus();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
rerender(
|
|
47
|
+
<Component {...props} items={[items[1], duplicate, items[0]]} />,
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByRole(role, { name: "Repeated second" })).toBe(focused);
|
|
50
|
+
expect(focused).toHaveFocus();
|
|
51
|
+
fireEvent.click(focused);
|
|
52
|
+
expect(scrollToOutlineItem).toHaveBeenLastCalledWith(duplicate, 1);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it("activates plain and rich headings with Enter", () => {
|
|
57
|
+
render(
|
|
58
|
+
<OutlineList
|
|
59
|
+
items={items}
|
|
60
|
+
activeHeaderId={undefined}
|
|
61
|
+
activeOccurrences={undefined}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
items.forEach((item) => {
|
|
65
|
+
const link = screen.getByRole("link", { name: item.name });
|
|
66
|
+
expect(link).toHaveAttribute("tabindex", "0");
|
|
67
|
+
fireEvent.keyDown(link, { key: "Enter" });
|
|
68
|
+
expect(scrollToOutlineItem).toHaveBeenLastCalledWith(item, 0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("names each minimap button", () => {
|
|
73
|
+
render(
|
|
74
|
+
<MiniMap
|
|
75
|
+
items={items}
|
|
76
|
+
activeHeaderId={undefined}
|
|
77
|
+
activeOccurrences={undefined}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
const button = screen.getByRole("button", { name: "Second" });
|
|
81
|
+
fireEvent.click(button);
|
|
82
|
+
expect(scrollToOutlineItem).toHaveBeenLastCalledWith(items[1], 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -17,6 +17,8 @@ export const FloatingOutline: React.FC = () => {
|
|
|
17
17
|
findOutlineElements(items),
|
|
18
18
|
);
|
|
19
19
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
20
|
+
const [isFocused, setIsFocused] = React.useState(false);
|
|
21
|
+
const isVisible = isHovered || isFocused;
|
|
20
22
|
|
|
21
23
|
// Hide if < 2 items
|
|
22
24
|
// It's kinda useless to have an outline with only one item
|
|
@@ -29,6 +31,12 @@ export const FloatingOutline: React.FC = () => {
|
|
|
29
31
|
<div
|
|
30
32
|
onMouseEnter={() => setIsHovered(true)}
|
|
31
33
|
onMouseLeave={() => setIsHovered(false)}
|
|
34
|
+
onFocus={() => setIsFocused(true)}
|
|
35
|
+
onBlur={(e) => {
|
|
36
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
37
|
+
setIsFocused(false);
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
32
40
|
className={cn(
|
|
33
41
|
"fixed top-[25vh] right-8 z-10000 print:hidden",
|
|
34
42
|
// Hide on small screens
|
|
@@ -36,9 +44,10 @@ export const FloatingOutline: React.FC = () => {
|
|
|
36
44
|
)}
|
|
37
45
|
>
|
|
38
46
|
<OutlineList
|
|
47
|
+
inert={!isVisible}
|
|
39
48
|
className={cn(
|
|
40
49
|
"-top-4 max-h-[70vh] bg-background rounded-lg shadow-lg absolute overflow-auto transition-all duration-300 w-[300px] border",
|
|
41
|
-
|
|
50
|
+
isVisible ? "left-[-280px] opacity-100" : "left-[300px] opacity-0",
|
|
42
51
|
)}
|
|
43
52
|
items={items}
|
|
44
53
|
activeHeaderId={activeHeaderId}
|
|
@@ -62,17 +71,20 @@ export const MiniMap: React.FC<{
|
|
|
62
71
|
const seen = new Map<string, number>();
|
|
63
72
|
return (
|
|
64
73
|
<div className="flex flex-col gap-4 items-end max-h-[70vh] overflow-hidden">
|
|
65
|
-
{items.map((item
|
|
74
|
+
{items.map((item) => {
|
|
66
75
|
const identifier = "id" in item.by ? item.by.id : item.by.path;
|
|
76
|
+
const selector = JSON.stringify(item.by);
|
|
67
77
|
// Keep track of how many times we've seen this selector
|
|
68
|
-
const occurrences = seen.get(
|
|
69
|
-
seen.set(
|
|
78
|
+
const occurrences = seen.get(selector) ?? 0;
|
|
79
|
+
seen.set(selector, occurrences + 1);
|
|
70
80
|
|
|
71
81
|
return (
|
|
72
|
-
<
|
|
73
|
-
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
aria-label={item.name}
|
|
85
|
+
key={`${selector}-${occurrences}`}
|
|
74
86
|
className={cn(
|
|
75
|
-
"h-[2px] bg-muted-foreground/60",
|
|
87
|
+
"h-[2px] bg-muted-foreground/60 focus-visible:outline focus-visible:outline-ring",
|
|
76
88
|
item.level === 1 && "w-5",
|
|
77
89
|
item.level === 2 && "w-4",
|
|
78
90
|
item.level === 3 && "w-3",
|
|
@@ -91,24 +103,31 @@ export const MiniMap: React.FC<{
|
|
|
91
103
|
|
|
92
104
|
export const OutlineList: React.FC<{
|
|
93
105
|
className?: string;
|
|
106
|
+
inert?: boolean;
|
|
94
107
|
items: OutlineItem[];
|
|
95
108
|
activeHeaderId: string | undefined;
|
|
96
109
|
activeOccurrences: number | undefined;
|
|
97
|
-
}> = ({ items, activeHeaderId, activeOccurrences, className }) => {
|
|
110
|
+
}> = ({ items, activeHeaderId, activeOccurrences, className, inert }) => {
|
|
98
111
|
// Map of selector to its occurrences
|
|
99
112
|
const seen = new Map<string, number>();
|
|
100
113
|
return (
|
|
101
|
-
<div
|
|
102
|
-
{
|
|
114
|
+
<div
|
|
115
|
+
inert={inert}
|
|
116
|
+
className={cn("flex flex-col overflow-auto py-4 pl-2", className)}
|
|
117
|
+
>
|
|
118
|
+
{items.map((item) => {
|
|
103
119
|
const identifier = "id" in item.by ? item.by.id : item.by.path;
|
|
120
|
+
const selector = JSON.stringify(item.by);
|
|
104
121
|
// Keep track of how many times we've seen this selector
|
|
105
|
-
const occurrences = seen.get(
|
|
106
|
-
seen.set(
|
|
122
|
+
const occurrences = seen.get(selector) ?? 0;
|
|
123
|
+
seen.set(selector, occurrences + 1);
|
|
107
124
|
|
|
108
|
-
const key = `${
|
|
125
|
+
const key = `${selector}-${occurrences}`;
|
|
109
126
|
const sharedProps = {
|
|
127
|
+
role: "link",
|
|
128
|
+
tabIndex: 0,
|
|
110
129
|
className: cn(
|
|
111
|
-
"px-2 py-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l",
|
|
130
|
+
"px-2 py-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l focus-visible:outline focus-visible:outline-ring",
|
|
112
131
|
item.level === 1 && "font-semibold",
|
|
113
132
|
item.level === 2 && "ml-3",
|
|
114
133
|
item.level === 3 && "ml-6",
|
|
@@ -118,6 +137,12 @@ export const OutlineList: React.FC<{
|
|
|
118
137
|
"text-accent-foreground",
|
|
119
138
|
),
|
|
120
139
|
onClick: () => scrollToOutlineItem(item, occurrences),
|
|
140
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
141
|
+
if (e.target === e.currentTarget && e.key === "Enter") {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
scrollToOutlineItem(item, occurrences);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
121
146
|
};
|
|
122
147
|
|
|
123
148
|
if (item.html) {
|
|
@@ -113,9 +113,10 @@ export function findOutlineElements(items: OutlineItem[]) {
|
|
|
113
113
|
return items
|
|
114
114
|
.map((item) => {
|
|
115
115
|
const identifier = "id" in item.by ? item.by.id : item.by.path;
|
|
116
|
+
const selector = JSON.stringify(item.by);
|
|
116
117
|
// Keep track of how many times we've seen this selector
|
|
117
|
-
const occurrences = seen.get(
|
|
118
|
-
seen.set(
|
|
118
|
+
const occurrences = seen.get(selector) ?? 0;
|
|
119
|
+
seen.set(selector, occurrences + 1);
|
|
119
120
|
|
|
120
121
|
const el = findOutlineItem(item, occurrences);
|
|
121
122
|
if (!el) {
|
|
@@ -66,6 +66,7 @@ const SecretsPanel: React.FC = () => {
|
|
|
66
66
|
<div className="flex justify-start h-8 border-b">
|
|
67
67
|
<button
|
|
68
68
|
type="button"
|
|
69
|
+
aria-label="Add environment variable"
|
|
69
70
|
className="border-r px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
|
|
70
71
|
onClick={() =>
|
|
71
72
|
openModal(
|
|
@@ -71,7 +71,11 @@ const SnippetsPanel: React.FC = () => {
|
|
|
71
71
|
rootClassName="flex-1 border-r"
|
|
72
72
|
/>
|
|
73
73
|
<ContributeSnippetButton>
|
|
74
|
-
<button
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
aria-label="Contribute snippet"
|
|
77
|
+
className="float-right px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
|
|
78
|
+
>
|
|
75
79
|
<PlusIcon className="h-4 w-4" />
|
|
76
80
|
</button>
|
|
77
81
|
</ContributeSnippetButton>
|
|
@@ -16,14 +16,14 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
16
16
|
import { toast } from "@/components/ui/use-toast";
|
|
17
17
|
import type { Attachment } from "@/core/attachments/state";
|
|
18
18
|
import { pairConfigAtom } from "@/core/config/pair";
|
|
19
|
-
import {
|
|
19
|
+
import { createNotebookReplica } from "@/core/host/replica";
|
|
20
20
|
import {
|
|
21
21
|
type StableSessionId,
|
|
22
22
|
stableSessionIdAtom,
|
|
23
23
|
} from "@/core/kernel/session";
|
|
24
24
|
import { API } from "@/core/network/api";
|
|
25
25
|
import { connectionAtom } from "@/core/network/connection";
|
|
26
|
-
import {
|
|
26
|
+
import { notebookReplicaAtom } from "@/core/websocket/state";
|
|
27
27
|
import { ConnectionState } from "@/core/websocket/types";
|
|
28
28
|
import { Deferred } from "@/utils/Deferred";
|
|
29
29
|
import { AttachmentStatus } from "../footer-items/attachment-status";
|
|
@@ -45,8 +45,8 @@ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
|
|
|
45
45
|
const store = createStore();
|
|
46
46
|
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
47
47
|
store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
|
|
48
|
-
store.set(
|
|
49
|
-
...
|
|
48
|
+
store.set(notebookReplicaAtom, {
|
|
49
|
+
...createNotebookReplica(store),
|
|
50
50
|
connectionAtom: atom({ state: ConnectionState.OPEN }),
|
|
51
51
|
attachmentsAtom,
|
|
52
52
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
import { userConfigAtom } from "@/core/config/config";
|
|
8
|
+
import { defaultUserConfig } from "@/core/config/config-schema";
|
|
9
|
+
import { getDefaultHotkey } from "@/core/hotkeys/hotkeys";
|
|
10
|
+
import {
|
|
11
|
+
KeyboardShortcuts,
|
|
12
|
+
keyboardShortcutsAtom,
|
|
13
|
+
} from "../keyboard-shortcuts";
|
|
14
|
+
|
|
15
|
+
const saveUserConfig = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
16
|
+
vi.mock("@/core/network/requests", () => ({
|
|
17
|
+
useRequestClient: () => ({ saveUserConfig }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe("shortcut recorder keyboard navigation", () => {
|
|
21
|
+
beforeEach(() => vi.clearAllMocks());
|
|
22
|
+
|
|
23
|
+
it("allows Tab navigation and Reset without saving a shortcut", async () => {
|
|
24
|
+
const store = createStore();
|
|
25
|
+
const config = defaultUserConfig();
|
|
26
|
+
store.set(userConfigAtom, {
|
|
27
|
+
...config,
|
|
28
|
+
keymap: { ...config.keymap, overrides: { "cell.run": "Ctrl-9" } },
|
|
29
|
+
});
|
|
30
|
+
store.set(keyboardShortcutsAtom, true);
|
|
31
|
+
render(
|
|
32
|
+
<Provider store={store}>
|
|
33
|
+
<TooltipProvider>
|
|
34
|
+
<KeyboardShortcuts />
|
|
35
|
+
</TooltipProvider>
|
|
36
|
+
</Provider>,
|
|
37
|
+
);
|
|
38
|
+
const edit = screen.getByRole("button", {
|
|
39
|
+
name: `Edit shortcut: ${getDefaultHotkey("cell.run").name}`,
|
|
40
|
+
});
|
|
41
|
+
act(() => edit.focus());
|
|
42
|
+
fireEvent.click(edit, { detail: 0 });
|
|
43
|
+
const recorder = screen.getByPlaceholderText(
|
|
44
|
+
getDefaultHotkey("cell.run").name,
|
|
45
|
+
);
|
|
46
|
+
expect(recorder).toHaveFocus();
|
|
47
|
+
expect(fireEvent.keyDown(recorder, { key: "Tab" })).toBe(true);
|
|
48
|
+
expect(fireEvent.keyDown(recorder, { key: "Tab", shiftKey: true })).toBe(
|
|
49
|
+
true,
|
|
50
|
+
);
|
|
51
|
+
expect(recorder).toBeInTheDocument();
|
|
52
|
+
expect(saveUserConfig).not.toHaveBeenCalled();
|
|
53
|
+
const reset = screen.getByRole("button", { name: /^Reset to default:/ });
|
|
54
|
+
act(() => reset.focus());
|
|
55
|
+
expect(reset).toHaveFocus();
|
|
56
|
+
await act(async () => fireEvent.click(reset, { detail: 0 }));
|
|
57
|
+
expect(saveUserConfig).toHaveBeenCalledExactlyOnceWith({
|
|
58
|
+
config: { keymap: { ...config.keymap, overrides: {} } },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -134,6 +134,9 @@ export const KeyboardShortcuts: React.FC = () => {
|
|
|
134
134
|
defaultValue={newShortcut.join("+")}
|
|
135
135
|
placeholder={hotkey.name}
|
|
136
136
|
onKeyDown={(e) => {
|
|
137
|
+
if (e.key === "Tab" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
137
140
|
e.preventDefault();
|
|
138
141
|
const next: string[] = [];
|
|
139
142
|
|
|
@@ -189,6 +192,7 @@ export const KeyboardShortcuts: React.FC = () => {
|
|
|
189
192
|
autoFocus={true}
|
|
190
193
|
endAdornment={
|
|
191
194
|
<Button
|
|
195
|
+
aria-label="Cancel shortcut recording"
|
|
192
196
|
variant="text"
|
|
193
197
|
size="xs"
|
|
194
198
|
className="mb-0"
|
|
@@ -206,13 +210,14 @@ export const KeyboardShortcuts: React.FC = () => {
|
|
|
206
210
|
Press a key combination
|
|
207
211
|
</span>
|
|
208
212
|
{defaultHotkey.key !== hotkey.key && (
|
|
209
|
-
<
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
210
215
|
className="text-xs cursor-pointer text-primary"
|
|
211
216
|
onClick={handleResetShortcut}
|
|
212
217
|
>
|
|
213
218
|
Reset to default:{" "}
|
|
214
219
|
<span className="font-mono">{defaultHotkey.key}</span>
|
|
215
|
-
</
|
|
220
|
+
</button>
|
|
216
221
|
)}
|
|
217
222
|
</div>
|
|
218
223
|
</div>
|
|
@@ -228,13 +233,17 @@ export const KeyboardShortcuts: React.FC = () => {
|
|
|
228
233
|
className="grid grid-cols-[auto_2fr_3fr] gap-2 items-center"
|
|
229
234
|
>
|
|
230
235
|
{hotkeys.isEditable(action) ? (
|
|
231
|
-
<
|
|
232
|
-
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-label={`Edit shortcut: ${hotkey.name}`}
|
|
239
|
+
className="cursor-pointer opacity-60 hover:opacity-100 text-muted-foreground w-3 h-3 focus-visible:outline focus-visible:outline-ring"
|
|
233
240
|
onClick={() => {
|
|
234
241
|
setNewShortcut([]);
|
|
235
242
|
setEditingShortcut(action);
|
|
236
243
|
}}
|
|
237
|
-
|
|
244
|
+
>
|
|
245
|
+
<EditIcon className="w-3 h-3" />
|
|
246
|
+
</button>
|
|
238
247
|
) : (
|
|
239
248
|
<div className="w-3 h-3" />
|
|
240
249
|
)}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "@/core/cells/error-report";
|
|
26
26
|
import { pairConfigAtom } from "@/core/config/pair";
|
|
27
27
|
import { notebookStateFromSession } from "@/core/cells/session";
|
|
28
|
-
import {
|
|
28
|
+
import { createNotebookReplica } from "@/core/host/replica";
|
|
29
29
|
import type { MarimoError, OutputMessage } from "@/core/kernel/messages";
|
|
30
30
|
import {
|
|
31
31
|
type StableSessionId,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from "@/core/kernel/session";
|
|
34
34
|
import { API } from "@/core/network/api";
|
|
35
35
|
import { connectionAtom } from "@/core/network/connection";
|
|
36
|
-
import {
|
|
36
|
+
import { notebookReplicaAtom } from "@/core/websocket/state";
|
|
37
37
|
import { ConnectionState } from "@/core/websocket/types";
|
|
38
38
|
import { Deferred } from "@/utils/Deferred";
|
|
39
39
|
import { HTTPError } from "@/utils/errors";
|
|
@@ -97,8 +97,8 @@ function renderButton({
|
|
|
97
97
|
const store = createStore();
|
|
98
98
|
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
99
99
|
store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
|
|
100
|
-
store.set(
|
|
101
|
-
...
|
|
100
|
+
store.set(notebookReplicaAtom, {
|
|
101
|
+
...createNotebookReplica(store),
|
|
102
102
|
connectionAtom: atom({ state: ConnectionState.OPEN }),
|
|
103
103
|
attachmentsAtom,
|
|
104
104
|
});
|
|
@@ -17,6 +17,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
17
17
|
import { requestClientAtom } from "@/core/network/requests";
|
|
18
18
|
import type { FileInfo, FileSearchResponse } from "@/core/network/types";
|
|
19
19
|
import { Deferred } from "@/utils/Deferred";
|
|
20
|
+
import { openNotebook } from "@/utils/links";
|
|
20
21
|
import type { FilePath } from "@/utils/paths";
|
|
21
22
|
import { TreeDndProvider } from "../dnd-wrapper";
|
|
22
23
|
import { FileExplorer } from "../file-explorer";
|
|
@@ -29,6 +30,11 @@ vi.mock("../file-viewer", () => ({
|
|
|
29
30
|
FileViewer: ({ file }: { file: FileInfo }) => <div>Preview: {file.name}</div>,
|
|
30
31
|
}));
|
|
31
32
|
|
|
33
|
+
vi.mock("@/utils/links", async (importOriginal) => ({
|
|
34
|
+
...(await importOriginal<typeof import("@/utils/links")>()),
|
|
35
|
+
openNotebook: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
32
38
|
function file(name: string, isDirectory = false): FileInfo {
|
|
33
39
|
return {
|
|
34
40
|
id: name,
|
|
@@ -91,6 +97,47 @@ beforeEach(() => {
|
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
describe("file browser navigation", () => {
|
|
100
|
+
it("tabs into the notebook action, returns to its row, and exits the tree", async () => {
|
|
101
|
+
client.sendListFiles.mockResolvedValue({
|
|
102
|
+
root: "/workspace",
|
|
103
|
+
files: [
|
|
104
|
+
{ ...file("notebook.py"), isMarimoFile: true },
|
|
105
|
+
file("notes.txt"),
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
render(
|
|
109
|
+
<>
|
|
110
|
+
<FileExplorer height={300} />
|
|
111
|
+
<button type="button">After tree</button>
|
|
112
|
+
</>,
|
|
113
|
+
{ wrapper },
|
|
114
|
+
);
|
|
115
|
+
const row = await screen.findByRole("treeitem", { name: "notebook.py" });
|
|
116
|
+
act(() => row.focus());
|
|
117
|
+
fireEvent.keyDown(row, { key: "Tab" });
|
|
118
|
+
const open = screen.getByRole("button", {
|
|
119
|
+
name: "Open notebook notebook.py",
|
|
120
|
+
});
|
|
121
|
+
expect(open).toHaveFocus();
|
|
122
|
+
fireEvent.keyDown(open, { key: "Tab", shiftKey: true });
|
|
123
|
+
expect(row).toHaveFocus();
|
|
124
|
+
fireEvent.keyDown(row, { key: "Tab" });
|
|
125
|
+
for (const key of ["Enter", " "]) {
|
|
126
|
+
expect(fireEvent.keyDown(open, { key })).toBe(true);
|
|
127
|
+
expect(
|
|
128
|
+
screen.queryByText("Preview: notebook.py"),
|
|
129
|
+
).not.toBeInTheDocument();
|
|
130
|
+
}
|
|
131
|
+
fireEvent.click(open);
|
|
132
|
+
expect(openNotebook).toHaveBeenCalledExactlyOnceWith("notebook.py");
|
|
133
|
+
fireEvent.keyDown(open, { key: "Tab" });
|
|
134
|
+
expect(screen.getByRole("button", { name: "After tree" })).toHaveFocus();
|
|
135
|
+
const plain = screen.getByRole("treeitem", { name: "notes.txt" });
|
|
136
|
+
act(() => plain.focus());
|
|
137
|
+
fireEvent.keyDown(plain, { key: "Tab" });
|
|
138
|
+
expect(screen.getByRole("button", { name: "After tree" })).toHaveFocus();
|
|
139
|
+
});
|
|
140
|
+
|
|
94
141
|
it("debounces the first character, subsequent edits, and searches after clearing", async () => {
|
|
95
142
|
render(<FileExplorer height={300} />, { wrapper });
|
|
96
143
|
const input = await screen.findByRole("textbox", {
|