@marimo-team/islands 0.23.3-dev3 → 0.23.3-dev33
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/{chat-ui-CTt4WX0V.js → chat-ui-BLFhPclV.js} +2 -2
- package/dist/{html-to-image-BdsDysfl.js → html-to-image-XYwXqg2E.js} +2107 -2107
- package/dist/main.js +1134 -1116
- package/dist/{process-output-COL2Pf5I.js → process-output-BDVjDpbu.js} +1 -1
- package/dist/{reveal-component-Cd5Y35Ny.js → reveal-component-DaX8Aj0s.js} +2 -2
- package/dist/{slide-BEerfanN.js → slide-C1t_W7WX.js} +571 -467
- package/package.json +2 -2
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +84 -2
- package/src/components/editor/file-tree/file-explorer.tsx +142 -203
- package/src/components/editor/file-tree/file-name-input.tsx +41 -0
- package/src/components/editor/file-tree/file-operations.tsx +266 -0
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/file-tree/requesting-tree.tsx +68 -49
- package/src/components/editor/output/JsonOutput.tsx +157 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +147 -2
- package/src/components/home/state.ts +13 -1
- package/src/components/pages/home-page.tsx +116 -10
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/network/requests-network.ts +0 -3
- package/src/core/static/export-context.ts +43 -0
- package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
- package/src/plugins/core/trusted-url.ts +23 -10
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
- package/src/utils/__tests__/path.test.ts +20 -0
- package/src/utils/pathUtils.test.ts +141 -1
- package/src/utils/pathUtils.ts +46 -0
- package/src/utils/paths.ts +9 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { CopyIcon, Edit3Icon, Trash2Icon } from "lucide-react";
|
|
4
|
+
import type React from "react";
|
|
5
|
+
import { useCallback } from "react";
|
|
6
|
+
import type { NodeApi } from "react-arborist";
|
|
7
|
+
import useEvent from "react-use-event-hook";
|
|
8
|
+
import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
9
|
+
import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
|
|
10
|
+
import {
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from "@/components/ui/dropdown-menu";
|
|
16
|
+
import { useRequestClient } from "@/core/network/requests";
|
|
17
|
+
import type { FileInfo } from "@/core/network/types";
|
|
18
|
+
import {
|
|
19
|
+
makeDuplicateName,
|
|
20
|
+
resolvePaths,
|
|
21
|
+
toAbsolutePath,
|
|
22
|
+
} from "@/utils/pathUtils";
|
|
23
|
+
import {
|
|
24
|
+
type FileOperationResult,
|
|
25
|
+
handleFileResponse,
|
|
26
|
+
} from "./requesting-tree";
|
|
27
|
+
import { MENU_ITEM_ICON_CLASS, MoreActionsButton } from "./tree-actions";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hook that exposes rename / duplicate / delete operations against absolute
|
|
31
|
+
* paths, handling path resolution (via `resolvePaths`) and error toasting.
|
|
32
|
+
*
|
|
33
|
+
* `root` is the workspace / tree root used to turn relative node paths into
|
|
34
|
+
* absolute paths for the API. Pass `""` if all node paths are already
|
|
35
|
+
* absolute (in which case `resolvePaths` is a no-op transformation).
|
|
36
|
+
*/
|
|
37
|
+
export function useFileOperations({ root }: { root: string }) {
|
|
38
|
+
const {
|
|
39
|
+
sendRenameFileOrFolder,
|
|
40
|
+
sendCopyFileOrFolder,
|
|
41
|
+
sendDeleteFileOrFolder,
|
|
42
|
+
} = useRequestClient();
|
|
43
|
+
|
|
44
|
+
const renameFile = useCallback(
|
|
45
|
+
async (
|
|
46
|
+
file: Pick<FileInfo, "path">,
|
|
47
|
+
newName: string,
|
|
48
|
+
): Promise<FileOperationResult> => {
|
|
49
|
+
const { path, newPath } = resolvePaths({
|
|
50
|
+
path: file.path,
|
|
51
|
+
name: newName,
|
|
52
|
+
root,
|
|
53
|
+
});
|
|
54
|
+
const resp = await sendRenameFileOrFolder({ path, newPath });
|
|
55
|
+
return handleFileResponse(resp);
|
|
56
|
+
},
|
|
57
|
+
[root, sendRenameFileOrFolder],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const duplicateFile = useCallback(
|
|
61
|
+
async (
|
|
62
|
+
file: Pick<FileInfo, "path" | "name">,
|
|
63
|
+
): Promise<FileOperationResult> => {
|
|
64
|
+
const { path, newPath } = resolvePaths({
|
|
65
|
+
path: file.path,
|
|
66
|
+
name: makeDuplicateName(file.name),
|
|
67
|
+
root,
|
|
68
|
+
});
|
|
69
|
+
const resp = await sendCopyFileOrFolder({ path, newPath });
|
|
70
|
+
return handleFileResponse(resp);
|
|
71
|
+
},
|
|
72
|
+
[root, sendCopyFileOrFolder],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const deleteFile = useCallback(
|
|
76
|
+
async (file: Pick<FileInfo, "path">): Promise<FileOperationResult> => {
|
|
77
|
+
const resp = await sendDeleteFileOrFolder({
|
|
78
|
+
path: toAbsolutePath(file.path, root),
|
|
79
|
+
});
|
|
80
|
+
return handleFileResponse(resp);
|
|
81
|
+
},
|
|
82
|
+
[root, sendDeleteFileOrFolder],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return { renameFile, duplicateFile, deleteFile };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FileItemKind = "file" | "folder" | "notebook";
|
|
89
|
+
|
|
90
|
+
const DELETE_TITLE_BY_KIND: Record<FileItemKind, string> = {
|
|
91
|
+
file: "Delete file",
|
|
92
|
+
folder: "Delete folder",
|
|
93
|
+
notebook: "Delete notebook",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function useConfirmDeleteFile() {
|
|
97
|
+
const { openConfirm } = useImperativeModal();
|
|
98
|
+
|
|
99
|
+
return useCallback(
|
|
100
|
+
(
|
|
101
|
+
target: { name: string; kind?: FileItemKind },
|
|
102
|
+
onConfirm: () => void | Promise<void>,
|
|
103
|
+
) => {
|
|
104
|
+
const kind = target.kind ?? "file";
|
|
105
|
+
openConfirm({
|
|
106
|
+
title: DELETE_TITLE_BY_KIND[kind],
|
|
107
|
+
description: `Are you sure you want to delete ${target.name}?`,
|
|
108
|
+
confirmAction: (
|
|
109
|
+
<AlertDialogDestructiveAction
|
|
110
|
+
onClick={async () => {
|
|
111
|
+
await onConfirm();
|
|
112
|
+
}}
|
|
113
|
+
aria-label="Confirm"
|
|
114
|
+
>
|
|
115
|
+
Delete
|
|
116
|
+
</AlertDialogDestructiveAction>
|
|
117
|
+
),
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
[openConfirm],
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* High-level handlers for rename/duplicate/delete against a single node
|
|
126
|
+
*
|
|
127
|
+
* All successful operations fire `onAfterChange` so callers can refresh their
|
|
128
|
+
* data sources (workspace tree + recent notebooks on the homepage).
|
|
129
|
+
*/
|
|
130
|
+
export function useNotebookFileActions({
|
|
131
|
+
node,
|
|
132
|
+
root,
|
|
133
|
+
onAfterChange,
|
|
134
|
+
}: {
|
|
135
|
+
node: NodeApi<FileInfo>;
|
|
136
|
+
root: string;
|
|
137
|
+
onAfterChange?: () => void;
|
|
138
|
+
}) {
|
|
139
|
+
const { duplicateFile, deleteFile } = useFileOperations({ root });
|
|
140
|
+
const confirmDelete = useConfirmDeleteFile();
|
|
141
|
+
|
|
142
|
+
const handleRename = useEvent(() => {
|
|
143
|
+
node.edit();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const handleDuplicate = useEvent(async () => {
|
|
147
|
+
const result = await duplicateFile(node.data);
|
|
148
|
+
if (result) {
|
|
149
|
+
onAfterChange?.();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const handleDelete = useEvent(() => {
|
|
154
|
+
const kind: FileItemKind = node.data.isDirectory
|
|
155
|
+
? "folder"
|
|
156
|
+
: node.data.isMarimoFile
|
|
157
|
+
? "notebook"
|
|
158
|
+
: "file";
|
|
159
|
+
confirmDelete({ name: node.data.name, kind }, async () => {
|
|
160
|
+
const result = await deleteFile(node.data);
|
|
161
|
+
if (result) {
|
|
162
|
+
onAfterChange?.();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return { handleRename, handleDuplicate, handleDelete };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const FileActionsDropdown = ({
|
|
171
|
+
testId,
|
|
172
|
+
buttonClassName,
|
|
173
|
+
iconClassName,
|
|
174
|
+
contentClassName,
|
|
175
|
+
preventDefaultOnTrigger = false,
|
|
176
|
+
children,
|
|
177
|
+
}: {
|
|
178
|
+
testId?: string;
|
|
179
|
+
buttonClassName?: string;
|
|
180
|
+
iconClassName?: string;
|
|
181
|
+
contentClassName?: string;
|
|
182
|
+
/**
|
|
183
|
+
* When true, the trigger also calls `preventDefault()` on click — needed
|
|
184
|
+
* when the dropdown is nested inside an `<a>` or other element whose
|
|
185
|
+
* default click behavior (navigation, submit, etc.) should be suppressed.
|
|
186
|
+
*/
|
|
187
|
+
preventDefaultOnTrigger?: boolean;
|
|
188
|
+
children: React.ReactNode;
|
|
189
|
+
}) => (
|
|
190
|
+
<DropdownMenu modal={false}>
|
|
191
|
+
<DropdownMenuTrigger
|
|
192
|
+
asChild={true}
|
|
193
|
+
tabIndex={-1}
|
|
194
|
+
onClick={(e) => {
|
|
195
|
+
e.stopPropagation();
|
|
196
|
+
if (preventDefaultOnTrigger) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<MoreActionsButton
|
|
202
|
+
data-testid={testId}
|
|
203
|
+
className={buttonClassName}
|
|
204
|
+
iconClassName={iconClassName}
|
|
205
|
+
/>
|
|
206
|
+
</DropdownMenuTrigger>
|
|
207
|
+
<DropdownMenuContent
|
|
208
|
+
align="end"
|
|
209
|
+
className={contentClassName ?? "print:hidden w-[220px]"}
|
|
210
|
+
onClick={(e) => e.stopPropagation()}
|
|
211
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
212
|
+
>
|
|
213
|
+
{children}
|
|
214
|
+
</DropdownMenuContent>
|
|
215
|
+
</DropdownMenu>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
export const RenameMenuItem = ({
|
|
219
|
+
onSelect,
|
|
220
|
+
disabled,
|
|
221
|
+
title,
|
|
222
|
+
}: {
|
|
223
|
+
onSelect: (evt: Event) => void;
|
|
224
|
+
disabled?: boolean;
|
|
225
|
+
title?: string;
|
|
226
|
+
}) => (
|
|
227
|
+
<DropdownMenuItem onSelect={onSelect} disabled={disabled} title={title}>
|
|
228
|
+
<Edit3Icon className={MENU_ITEM_ICON_CLASS} />
|
|
229
|
+
Rename
|
|
230
|
+
</DropdownMenuItem>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
export const DuplicateMenuItem = ({
|
|
234
|
+
onSelect,
|
|
235
|
+
disabled,
|
|
236
|
+
title,
|
|
237
|
+
}: {
|
|
238
|
+
onSelect: (evt: Event) => void;
|
|
239
|
+
disabled?: boolean;
|
|
240
|
+
title?: string;
|
|
241
|
+
}) => (
|
|
242
|
+
<DropdownMenuItem onSelect={onSelect} disabled={disabled} title={title}>
|
|
243
|
+
<CopyIcon className={MENU_ITEM_ICON_CLASS} />
|
|
244
|
+
Duplicate
|
|
245
|
+
</DropdownMenuItem>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
export const DeleteMenuItem = ({
|
|
249
|
+
onSelect,
|
|
250
|
+
disabled,
|
|
251
|
+
title,
|
|
252
|
+
}: {
|
|
253
|
+
onSelect: (evt: Event) => void;
|
|
254
|
+
disabled?: boolean;
|
|
255
|
+
title?: string;
|
|
256
|
+
}) => (
|
|
257
|
+
<DropdownMenuItem
|
|
258
|
+
onSelect={onSelect}
|
|
259
|
+
variant="danger"
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
title={title}
|
|
262
|
+
>
|
|
263
|
+
<Trash2Icon className={MENU_ITEM_ICON_CLASS} />
|
|
264
|
+
Delete
|
|
265
|
+
</DropdownMenuItem>
|
|
266
|
+
);
|
|
@@ -88,7 +88,7 @@ export const CsvViewer: React.FC<{ contents: string }> = ({ contents }) => {
|
|
|
88
88
|
setPaginationState={setPagination}
|
|
89
89
|
wrapperClassName="h-full justify-between pb-1 px-1"
|
|
90
90
|
pagination={true}
|
|
91
|
-
className="rounded-none border-b flex overflow-hidden"
|
|
91
|
+
className="rounded-none border-b flex flex-col overflow-hidden"
|
|
92
92
|
rowSelection={Objects.EMPTY}
|
|
93
93
|
/>
|
|
94
94
|
);
|
|
@@ -10,6 +10,26 @@ import type {
|
|
|
10
10
|
import { prettyError } from "@/utils/errors";
|
|
11
11
|
import { Functions } from "@/utils/functions";
|
|
12
12
|
import { type FilePath, PathBuilder } from "@/utils/paths";
|
|
13
|
+
import { resolvePaths } from "@/utils/pathUtils";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalized result of a file mutation: the server response when successful,
|
|
17
|
+
* `null` when the server rejected the request and a toast was surfaced.
|
|
18
|
+
*/
|
|
19
|
+
export type FileOperationResult = FileUpdateResponse | null;
|
|
20
|
+
|
|
21
|
+
export function handleFileResponse(
|
|
22
|
+
response: FileUpdateResponse,
|
|
23
|
+
): FileOperationResult {
|
|
24
|
+
if (!response.success) {
|
|
25
|
+
toast({
|
|
26
|
+
title: "Failed",
|
|
27
|
+
description: response.message,
|
|
28
|
+
});
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return response;
|
|
32
|
+
}
|
|
13
33
|
|
|
14
34
|
export class RequestingTree {
|
|
15
35
|
private delegate = new SimpleTree<FileInfo>([]);
|
|
@@ -85,15 +105,15 @@ export class RequestingTree {
|
|
|
85
105
|
});
|
|
86
106
|
return;
|
|
87
107
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
const { path, newPath } = resolvePaths({
|
|
109
|
+
path: node.data.path,
|
|
110
|
+
name: newName,
|
|
111
|
+
root: this.rootPath,
|
|
112
|
+
});
|
|
113
|
+
const parentPath = this.path.dirname(path);
|
|
91
114
|
const newFile = await this.callbacks
|
|
92
|
-
.copyFileOrFolder({
|
|
93
|
-
|
|
94
|
-
newPath: newPath,
|
|
95
|
-
})
|
|
96
|
-
.then(this.handleResponse);
|
|
115
|
+
.copyFileOrFolder({ path, newPath })
|
|
116
|
+
.then(handleFileResponse);
|
|
97
117
|
if (!newFile?.info) {
|
|
98
118
|
return;
|
|
99
119
|
}
|
|
@@ -116,14 +136,18 @@ export class RequestingTree {
|
|
|
116
136
|
});
|
|
117
137
|
return;
|
|
118
138
|
}
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
})
|
|
126
|
-
.then(
|
|
139
|
+
const { path, newPath } = resolvePaths({
|
|
140
|
+
path: node.data.path,
|
|
141
|
+
name,
|
|
142
|
+
root: this.rootPath,
|
|
143
|
+
});
|
|
144
|
+
const result = await this.callbacks
|
|
145
|
+
.renameFileOrFolder({ path, newPath })
|
|
146
|
+
.then(handleFileResponse);
|
|
147
|
+
if (!result) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
this.delegate.update({ id, changes: { name, path: newPath } });
|
|
128
152
|
this.onChange(this.delegate.data);
|
|
129
153
|
// Rename all of its children
|
|
@@ -136,23 +160,25 @@ export class RequestingTree {
|
|
|
136
160
|
: this.rootPath;
|
|
137
161
|
|
|
138
162
|
await Promise.all(
|
|
139
|
-
fromIds.map((id) => {
|
|
140
|
-
this.delegate.move({ id, parentId, index: 0 });
|
|
163
|
+
fromIds.map(async (id) => {
|
|
141
164
|
const node = this.delegate.find(id);
|
|
142
165
|
if (!node) {
|
|
143
|
-
return
|
|
166
|
+
return;
|
|
144
167
|
}
|
|
168
|
+
const originalPath = node.data.path;
|
|
145
169
|
const newPath = this.path.join(
|
|
146
170
|
parentPath,
|
|
147
|
-
this.path.basename(
|
|
171
|
+
this.path.basename(originalPath as FilePath),
|
|
148
172
|
);
|
|
173
|
+
const result = await this.callbacks
|
|
174
|
+
.renameFileOrFolder({ path: originalPath, newPath })
|
|
175
|
+
.then(handleFileResponse);
|
|
176
|
+
if (!result) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.delegate.move({ id, parentId, index: 0 });
|
|
149
181
|
this.delegate.update({ id, changes: { path: newPath } });
|
|
150
|
-
return this.callbacks
|
|
151
|
-
.renameFileOrFolder({
|
|
152
|
-
path: node.data.path,
|
|
153
|
-
newPath: newPath,
|
|
154
|
-
})
|
|
155
|
-
.then(this.handleResponse);
|
|
156
182
|
}),
|
|
157
183
|
);
|
|
158
184
|
|
|
@@ -162,17 +188,21 @@ export class RequestingTree {
|
|
|
162
188
|
await this.refreshAll([parentPath]);
|
|
163
189
|
}
|
|
164
190
|
|
|
165
|
-
async createFile(
|
|
166
|
-
name
|
|
167
|
-
parentId
|
|
168
|
-
type
|
|
169
|
-
|
|
191
|
+
async createFile({
|
|
192
|
+
name,
|
|
193
|
+
parentId,
|
|
194
|
+
type = "file",
|
|
195
|
+
}: {
|
|
196
|
+
name: string;
|
|
197
|
+
parentId: string | null;
|
|
198
|
+
type?: "file" | "notebook";
|
|
199
|
+
}): Promise<void> {
|
|
170
200
|
const parentPath = parentId
|
|
171
201
|
? (this.delegate.find(parentId)?.data.path ?? parentId)
|
|
172
202
|
: this.rootPath;
|
|
173
203
|
const newFile = await this.callbacks
|
|
174
204
|
.createFileOrFolder({ path: parentPath, type: type, name: name })
|
|
175
|
-
.then(
|
|
205
|
+
.then(handleFileResponse);
|
|
176
206
|
if (!newFile?.info) {
|
|
177
207
|
return;
|
|
178
208
|
}
|
|
@@ -192,7 +222,7 @@ export class RequestingTree {
|
|
|
192
222
|
: this.rootPath;
|
|
193
223
|
const newFolder = await this.callbacks
|
|
194
224
|
.createFileOrFolder({ path: parentPath, type: "directory", name: name })
|
|
195
|
-
.then(
|
|
225
|
+
.then(handleFileResponse);
|
|
196
226
|
if (!newFolder?.info) {
|
|
197
227
|
return;
|
|
198
228
|
}
|
|
@@ -216,9 +246,12 @@ export class RequestingTree {
|
|
|
216
246
|
return;
|
|
217
247
|
}
|
|
218
248
|
|
|
219
|
-
await this.callbacks
|
|
249
|
+
const result = await this.callbacks
|
|
220
250
|
.deleteFileOrFolder({ path: node.data.path })
|
|
221
|
-
.then(
|
|
251
|
+
.then(handleFileResponse);
|
|
252
|
+
if (!result) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
222
255
|
this.delegate.drop({ id });
|
|
223
256
|
this.onChange(this.delegate.data);
|
|
224
257
|
}
|
|
@@ -262,18 +295,4 @@ export class RequestingTree {
|
|
|
262
295
|
}
|
|
263
296
|
return path;
|
|
264
297
|
};
|
|
265
|
-
|
|
266
|
-
private handleResponse = (
|
|
267
|
-
response: FileUpdateResponse,
|
|
268
|
-
): FileUpdateResponse | null => {
|
|
269
|
-
if (!response.success) {
|
|
270
|
-
toast({
|
|
271
|
-
title: "Failed",
|
|
272
|
-
description: response.message,
|
|
273
|
-
});
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return response;
|
|
278
|
-
};
|
|
279
298
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
floatType,
|
|
9
9
|
intType,
|
|
10
10
|
JsonViewer,
|
|
11
|
+
type JsonViewerKeyRenderer,
|
|
11
12
|
nullType,
|
|
12
13
|
objectType,
|
|
13
14
|
stringType,
|
|
@@ -130,6 +131,9 @@ export const JsonOutput: React.FC<Props> = memo(
|
|
|
130
131
|
collapseStringsAfterLength={COLLAPSED_TEXT_LENGTH}
|
|
131
132
|
// leave the default valueTypes as it was - 'python', only 'json' is changed
|
|
132
133
|
valueTypes={valueTypesMap[valueTypes]}
|
|
134
|
+
// Render dict keys that carry Python type info (e.g. `int`, `tuple`).
|
|
135
|
+
// See `_key_formatter` in marimo/_output/formatters/structures.py.
|
|
136
|
+
keyRenderer={valueTypes === "python" ? keyRenderer : undefined}
|
|
133
137
|
// Don't group arrays, it will make the tree view look like there are nested arrays
|
|
134
138
|
groupArraysAfterLength={Number.MAX_SAFE_INTEGER}
|
|
135
139
|
// Built-in clipboard shifts content on hover
|
|
@@ -207,7 +211,10 @@ const LEAF_RENDERERS: Record<string, LeafRenderer> = {
|
|
|
207
211
|
),
|
|
208
212
|
"text/plain+float:": (value) => <span>{value}</span>,
|
|
209
213
|
"text/plain+bigint:": (value) => <span>{value}</span>,
|
|
210
|
-
"text/plain+set:": (value) => <span>
|
|
214
|
+
"text/plain+set:": (value) => <span>{formatSetPayload(value)}</span>,
|
|
215
|
+
"text/plain+frozenset:": (value) => (
|
|
216
|
+
<span>{formatFrozensetPayload(value)}</span>
|
|
217
|
+
),
|
|
211
218
|
"text/plain+tuple:": (value) => <span>{value}</span>,
|
|
212
219
|
"text/plain:": (value) => <CollapsibleTextOutput text={value} />,
|
|
213
220
|
"application/json:": (value) => (
|
|
@@ -375,6 +382,99 @@ function renderLeaf(leaf: string, render: LeafRenderer): React.ReactNode {
|
|
|
375
382
|
return <span>{leaf}</span>;
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
// Prefix marking keys that carry encoded type information from Python.
|
|
386
|
+
// See `_key_formatter` in marimo/_output/formatters/structures.py.
|
|
387
|
+
const KEY_ENCODED_PREFIX = "text/plain+";
|
|
388
|
+
|
|
389
|
+
// Format a JSON-list payload as a Python tuple literal. 1-element tuples
|
|
390
|
+
// need a trailing comma — `(1)` is just `1` in Python, `(1,)` is the tuple.
|
|
391
|
+
function formatTuplePayload(jsonList: string): string {
|
|
392
|
+
const items = JSON.parse(jsonList) as unknown[];
|
|
393
|
+
const inner = items.map((x) => JSON.stringify(x)).join(", ");
|
|
394
|
+
if (items.length === 0) {
|
|
395
|
+
return "()";
|
|
396
|
+
}
|
|
397
|
+
if (items.length === 1) {
|
|
398
|
+
return `(${inner},)`;
|
|
399
|
+
}
|
|
400
|
+
return `(${inner})`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Format a JSON-list payload as a Python frozenset literal. Empty → `frozenset()`
|
|
404
|
+
// rather than `frozenset({})` (which reads like a dict).
|
|
405
|
+
function formatFrozensetPayload(jsonList: string): string {
|
|
406
|
+
const items = JSON.parse(jsonList) as unknown[];
|
|
407
|
+
if (items.length === 0) {
|
|
408
|
+
return "frozenset()";
|
|
409
|
+
}
|
|
410
|
+
const inner = items.map((x) => JSON.stringify(x)).join(", ");
|
|
411
|
+
return `frozenset({${inner}})`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Format a JSON-list payload as a Python set literal. Empty → `set()`
|
|
415
|
+
// (not `{}`, which is a dict literal in Python).
|
|
416
|
+
function formatSetPayload(jsonList: string): string {
|
|
417
|
+
try {
|
|
418
|
+
const items = JSON.parse(jsonList) as unknown[];
|
|
419
|
+
if (items.length === 0) {
|
|
420
|
+
return "set()";
|
|
421
|
+
}
|
|
422
|
+
const inner = items.map((x) => JSON.stringify(x)).join(", ");
|
|
423
|
+
return `{${inner}}`;
|
|
424
|
+
} catch {
|
|
425
|
+
// Back-compat: older wire form was `text/plain+set:{1, 2, 3}` (Python
|
|
426
|
+
// set-literal string, not JSON). Pass it through as-is rather than crash.
|
|
427
|
+
return jsonList;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Renderers for decoded non-string keys. Visual affordances match Python:
|
|
432
|
+
// unquoted primitives, parens for tuple, `frozenset({...})` for frozenset,
|
|
433
|
+
// and the `text/plain+str:` escape re-quotes the original string.
|
|
434
|
+
const KEY_DECODERS: Record<string, (data: string) => React.ReactNode> = {
|
|
435
|
+
"text/plain+int:": (v) => <span>{v}</span>,
|
|
436
|
+
"text/plain+float:": (v) => <span>{v}</span>,
|
|
437
|
+
"text/plain+bool:": (v) => <span>{v === "True" ? "True" : "False"}</span>,
|
|
438
|
+
"text/plain+none:": () => <span>None</span>,
|
|
439
|
+
"text/plain+tuple:": (v) => <span>{formatTuplePayload(v)}</span>,
|
|
440
|
+
"text/plain+frozenset:": (v) => <span>{formatFrozensetPayload(v)}</span>,
|
|
441
|
+
"text/plain+str:": (v) => <span>"{v}"</span>,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
function isEncodedKey(key: unknown): key is string {
|
|
445
|
+
return typeof key === "string" && key.startsWith(KEY_ENCODED_PREFIX);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// `@textea/json-viewer` drops quotes from integer-like string keys, which
|
|
449
|
+
// makes the string `"2"` visually identical to the decoded int `2`. Match
|
|
450
|
+
// the same keys the viewer strips and render them with explicit quotes.
|
|
451
|
+
const INT_LIKE_STRING = /^-?\d+$/;
|
|
452
|
+
|
|
453
|
+
const keyRenderer: JsonViewerKeyRenderer = Object.assign(
|
|
454
|
+
({ path }: DataItemProps) => {
|
|
455
|
+
const key = path[path.length - 1];
|
|
456
|
+
if (typeof key !== "string") {
|
|
457
|
+
return <span>{String(key)}</span>;
|
|
458
|
+
}
|
|
459
|
+
if (isEncodedKey(key)) {
|
|
460
|
+
const [data, mimeType] = leafDataAndMimeType(key);
|
|
461
|
+
const render = KEY_DECODERS[`${mimeType}:`];
|
|
462
|
+
return render ? render(data) : <span>{key}</span>;
|
|
463
|
+
}
|
|
464
|
+
// Plain integer-like string — quote it so it's distinct from a decoded int.
|
|
465
|
+
return <span>"{key}"</span>;
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
when: ({ path }: DataItemProps) => {
|
|
469
|
+
const key = path[path.length - 1];
|
|
470
|
+
return (
|
|
471
|
+
isEncodedKey(key) ||
|
|
472
|
+
(typeof key === "string" && INT_LIKE_STRING.test(key))
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
|
|
378
478
|
const MIME_PREFIXES = Object.keys(LEAF_RENDERERS);
|
|
379
479
|
const REPLACE_PREFIX = "<marimo-replace>";
|
|
380
480
|
const REPLACE_SUFFIX = "</marimo-replace>";
|
|
@@ -413,8 +513,10 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
|
|
|
413
513
|
return `${REPLACE_PREFIX}(${leafData(value).slice(1, -1)})${REPLACE_SUFFIX}`;
|
|
414
514
|
}
|
|
415
515
|
if (value.startsWith("text/plain+set:")) {
|
|
416
|
-
|
|
417
|
-
|
|
516
|
+
return `${REPLACE_PREFIX}${formatSetPayload(leafData(value))}${REPLACE_SUFFIX}`;
|
|
517
|
+
}
|
|
518
|
+
if (value.startsWith("text/plain+frozenset:")) {
|
|
519
|
+
return `${REPLACE_PREFIX}${formatFrozensetPayload(leafData(value))}${REPLACE_SUFFIX}`;
|
|
418
520
|
}
|
|
419
521
|
|
|
420
522
|
if (MIME_PREFIXES.some((prefix) => value.startsWith(prefix))) {
|
|
@@ -428,10 +530,61 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
|
|
|
428
530
|
return value;
|
|
429
531
|
}
|
|
430
532
|
|
|
533
|
+
// Rewrite an encoded key string into the Python literal that should appear
|
|
534
|
+
// unquoted in the copy output. Wrapping in REPLACE_PREFIX/SUFFIX makes the
|
|
535
|
+
// final regex pass strip the surrounding JSON quotes.
|
|
536
|
+
function decodeKeyForCopy(key: string): string {
|
|
537
|
+
const [data, mimeType] = leafDataAndMimeType(key);
|
|
538
|
+
const wrap = (s: string) => `${REPLACE_PREFIX}${s}${REPLACE_SUFFIX}`;
|
|
539
|
+
switch (`${mimeType}:`) {
|
|
540
|
+
case "text/plain+int:":
|
|
541
|
+
return wrap(data);
|
|
542
|
+
case "text/plain+float:":
|
|
543
|
+
if (data === "nan") {
|
|
544
|
+
return wrap("float('nan')");
|
|
545
|
+
}
|
|
546
|
+
if (data === "inf") {
|
|
547
|
+
return wrap("float('inf')");
|
|
548
|
+
}
|
|
549
|
+
if (data === "-inf") {
|
|
550
|
+
return wrap("-float('inf')");
|
|
551
|
+
}
|
|
552
|
+
return wrap(data);
|
|
553
|
+
case "text/plain+bool:":
|
|
554
|
+
return wrap(data === "True" ? "True" : "False");
|
|
555
|
+
case "text/plain+none:":
|
|
556
|
+
return wrap("None");
|
|
557
|
+
case "text/plain+tuple:":
|
|
558
|
+
return wrap(formatTuplePayload(data));
|
|
559
|
+
case "text/plain+frozenset:":
|
|
560
|
+
return wrap(formatFrozensetPayload(data));
|
|
561
|
+
case "text/plain+str:":
|
|
562
|
+
// `data` is the original Python string; it stays quoted.
|
|
563
|
+
return data;
|
|
564
|
+
default:
|
|
565
|
+
return key;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function rewriteEncodedKeys(value: unknown): unknown {
|
|
570
|
+
if (Array.isArray(value)) {
|
|
571
|
+
return value.map(rewriteEncodedKeys);
|
|
572
|
+
}
|
|
573
|
+
if (typeof value === "object" && value !== null) {
|
|
574
|
+
const out: Record<string, unknown> = {};
|
|
575
|
+
for (const [k, v] of Object.entries(value)) {
|
|
576
|
+
const newKey = isEncodedKey(k) ? decodeKeyForCopy(k) : k;
|
|
577
|
+
out[newKey] = rewriteEncodedKeys(v);
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
return value;
|
|
582
|
+
}
|
|
583
|
+
|
|
431
584
|
export function getCopyValue(value: unknown): string {
|
|
432
585
|
// Because this results in valid json, it adds quotes around None and True/False.
|
|
433
586
|
// but we want to make this look like Python, so we remove the quotes.
|
|
434
|
-
return JSON.stringify(value, pythonJsonReplacer, 2)
|
|
587
|
+
return JSON.stringify(rewriteEncodedKeys(value), pythonJsonReplacer, 2)
|
|
435
588
|
.replaceAll(`"${REPLACE_PREFIX}`, "")
|
|
436
589
|
.replaceAll(`${REPLACE_SUFFIX}"`, "");
|
|
437
590
|
}
|