@marimo-team/islands 0.23.10-dev42 → 0.23.10-dev44
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-CqtCMdc-.js → chat-ui-BxwraqS-.js} +3 -3
- package/dist/{chunk-5FQGJX7Z-BNjes6Yx.js → chunk-5FQGJX7Z-BbqSm5gU.js} +1227 -1222
- package/dist/{code-block-37QAKDTI-m92Yc8pv.js → code-block-37QAKDTI-O6IH6wAi.js} +1 -1
- package/dist/{code-visibility-BIrMe3pI.js → code-visibility-CzYIDGEy.js} +2 -2
- package/dist/{html-to-image-BviMxrkm.js → html-to-image-DXSdAI_Y.js} +2129 -2113
- package/dist/main.js +6 -6
- package/dist/{mermaid-4DMBBIKO-DIdL224_.js → mermaid-4DMBBIKO-Eg3D4FVz.js} +1 -1
- package/dist/{process-output-Bq3VMBsg.js → process-output-uozPQhBl.js} +1 -1
- package/dist/{reveal-component-cGpBwp6O.js → reveal-component-DERGOPiW.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +2 -1
- package/src/components/chat/chat-display.tsx +2 -2
- package/src/components/markdown/__tests__/markdown-renderer.test.tsx +43 -0
- package/src/components/markdown/markdown-renderer.tsx +24 -1
- package/src/components/storage/storage-inspector.tsx +51 -0
- package/src/core/storage/__tests__/state.test.ts +49 -0
- package/src/core/storage/__tests__/useStorageEntries.test.tsx +199 -3
- package/src/core/storage/state.ts +124 -13
- package/src/core/storage/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marimo-team/islands",
|
|
3
|
-
"version": "0.23.10-
|
|
3
|
+
"version": "0.23.10-dev44",
|
|
4
4
|
"main": "dist/main.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -141,6 +141,7 @@
|
|
|
141
141
|
"react-vega": "^8.0.0",
|
|
142
142
|
"react-virtuoso": "^4.18.1",
|
|
143
143
|
"reactflow": "^11.11.4",
|
|
144
|
+
"rehype-sanitize": "^6.0.0",
|
|
144
145
|
"remark-gfm": "^4.0.1",
|
|
145
146
|
"reveal.js": "^6.0.0",
|
|
146
147
|
"rpc-anywhere": "^1.7.0",
|
|
@@ -60,8 +60,8 @@ export const renderUIMessage = ({
|
|
|
60
60
|
|
|
61
61
|
switch (part.type) {
|
|
62
62
|
case "text":
|
|
63
|
-
// Streamdown
|
|
64
|
-
//
|
|
63
|
+
// Streamdown strips marimo elements, so render them with our own HTML
|
|
64
|
+
// renderer. Other markdown (incl. mo.md HTML) goes through Streamdown.
|
|
65
65
|
if (part.text.includes("<marimo-")) {
|
|
66
66
|
return (
|
|
67
67
|
<React.Fragment key={index}>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { MarkdownRenderer } from "../markdown-renderer";
|
|
5
|
+
|
|
6
|
+
describe("MarkdownRenderer", () => {
|
|
7
|
+
// Regression test for https://github.com/marimo-team/marimo/issues/9847
|
|
8
|
+
it("preserves class names on raw HTML (e.g. marimo admonitions)", async () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<MarkdownRenderer
|
|
11
|
+
content={
|
|
12
|
+
'<div class="admonition error">' +
|
|
13
|
+
'<p class="admonition-title">Error</p>' +
|
|
14
|
+
"<p>Nope!</p>" +
|
|
15
|
+
"</div>"
|
|
16
|
+
}
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
await waitFor(() => {
|
|
21
|
+
expect(screen.getByText("Nope!")).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const admonition = container.querySelector(".admonition.error");
|
|
25
|
+
expect(admonition).toBeInTheDocument();
|
|
26
|
+
expect(container.querySelector(".admonition-title")).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("strips unsafe tags while keeping class names", async () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<MarkdownRenderer
|
|
32
|
+
content={'<div class="safe"><script>alert(1)</script>hello</div>'}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(screen.getByText("hello")).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(container.querySelector(".safe")).toBeInTheDocument();
|
|
41
|
+
expect(container.querySelector("script")).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -4,7 +4,12 @@ import { EditorView } from "@codemirror/view";
|
|
|
4
4
|
import { useAtomValue } from "jotai";
|
|
5
5
|
import { BetweenHorizontalStartIcon } from "lucide-react";
|
|
6
6
|
import { memo, Suspense, useState } from "react";
|
|
7
|
-
import
|
|
7
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
8
|
+
import {
|
|
9
|
+
defaultRehypePlugins,
|
|
10
|
+
Streamdown,
|
|
11
|
+
type StreamdownProps,
|
|
12
|
+
} from "streamdown";
|
|
8
13
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
|
9
14
|
import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
|
|
10
15
|
import { useCellActions } from "@/core/cells/cells";
|
|
@@ -148,6 +153,23 @@ const CopyButton: React.FC<ButtonProps> = ({ onClick, ...props }) => {
|
|
|
148
153
|
);
|
|
149
154
|
};
|
|
150
155
|
|
|
156
|
+
// Allow `className` on every element; the default GitHub schema strips it,
|
|
157
|
+
// which drops the styling on marimo HTML like `mo.md(...)` admonitions.
|
|
158
|
+
const sanitizeSchema = {
|
|
159
|
+
...defaultSchema,
|
|
160
|
+
attributes: {
|
|
161
|
+
...defaultSchema.attributes,
|
|
162
|
+
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className"],
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Keep Streamdown's other rehype plugins (raw, harden) so scripts and unsafe
|
|
167
|
+
// URLs are still sanitized.
|
|
168
|
+
const REHYPE_PLUGINS: StreamdownProps["rehypePlugins"] = Object.values({
|
|
169
|
+
...defaultRehypePlugins,
|
|
170
|
+
sanitize: [rehypeSanitize, sanitizeSchema],
|
|
171
|
+
});
|
|
172
|
+
|
|
151
173
|
type Components = StreamdownProps["components"];
|
|
152
174
|
|
|
153
175
|
const COMPONENTS: Components = {
|
|
@@ -187,6 +209,7 @@ export const MarkdownRenderer = memo(({ content }: { content: string }) => {
|
|
|
187
209
|
<Streamdown
|
|
188
210
|
components={COMPONENTS}
|
|
189
211
|
plugins={plugins}
|
|
212
|
+
rehypePlugins={REHYPE_PLUGINS}
|
|
190
213
|
className="mo-markdown-renderer"
|
|
191
214
|
>
|
|
192
215
|
{content}
|
|
@@ -153,6 +153,33 @@ function filterEntries(
|
|
|
153
153
|
return entries.filter((entry) => entryMatchesSearch(entry, context));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
const LoadMoreStorageEntries: React.FC<{
|
|
157
|
+
depth: number;
|
|
158
|
+
isLoading: boolean;
|
|
159
|
+
error?: Error;
|
|
160
|
+
onLoadMore: () => void;
|
|
161
|
+
}> = ({ depth, isLoading, error, onLoadMore }) => {
|
|
162
|
+
return (
|
|
163
|
+
<div className="py-px text-xs" style={indentStyle(depth)}>
|
|
164
|
+
<Button
|
|
165
|
+
variant="text"
|
|
166
|
+
size="xs"
|
|
167
|
+
className="h-6 px-0 hover:text-blue-600"
|
|
168
|
+
disabled={isLoading}
|
|
169
|
+
onClick={onLoadMore}
|
|
170
|
+
>
|
|
171
|
+
{isLoading && <LoaderCircle className="h-3 w-3 mr-1 animate-spin" />}
|
|
172
|
+
{isLoading ? "Loading..." : "Load more"}
|
|
173
|
+
</Button>
|
|
174
|
+
{error && (
|
|
175
|
+
<span className="ml-2 text-destructive">
|
|
176
|
+
Failed to load: {error.message}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
156
183
|
/**
|
|
157
184
|
* Lazily loaded children of a directory entry.
|
|
158
185
|
* Caches fetched entries in the Jotai store so re-expanding doesn't re-fetch.
|
|
@@ -183,6 +210,10 @@ const StorageEntryChildren: React.FC<{
|
|
|
183
210
|
entries: children,
|
|
184
211
|
isPending,
|
|
185
212
|
error,
|
|
213
|
+
hasMore,
|
|
214
|
+
loadMore,
|
|
215
|
+
isLoadingMore,
|
|
216
|
+
loadMoreError,
|
|
186
217
|
} = useStorageEntries(namespace, prefix);
|
|
187
218
|
|
|
188
219
|
if (isPending) {
|
|
@@ -242,6 +273,14 @@ const StorageEntryChildren: React.FC<{
|
|
|
242
273
|
/>
|
|
243
274
|
);
|
|
244
275
|
})}
|
|
276
|
+
{hasMore && (
|
|
277
|
+
<LoadMoreStorageEntries
|
|
278
|
+
depth={depth}
|
|
279
|
+
isLoading={isLoadingMore}
|
|
280
|
+
error={loadMoreError}
|
|
281
|
+
onLoadMore={loadMore}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
245
284
|
</>
|
|
246
285
|
);
|
|
247
286
|
};
|
|
@@ -461,6 +500,10 @@ const StorageNamespaceSection: React.FC<{
|
|
|
461
500
|
entries: fetchedEntries,
|
|
462
501
|
isPending,
|
|
463
502
|
error,
|
|
503
|
+
hasMore,
|
|
504
|
+
loadMore,
|
|
505
|
+
isLoadingMore,
|
|
506
|
+
loadMoreError,
|
|
464
507
|
refetch,
|
|
465
508
|
} = useStorageEntries(namespaceName);
|
|
466
509
|
|
|
@@ -559,6 +602,14 @@ const StorageNamespaceSection: React.FC<{
|
|
|
559
602
|
/>
|
|
560
603
|
);
|
|
561
604
|
})}
|
|
605
|
+
{hasMore && (
|
|
606
|
+
<LoadMoreStorageEntries
|
|
607
|
+
depth={1}
|
|
608
|
+
isLoading={isLoadingMore}
|
|
609
|
+
error={loadMoreError}
|
|
610
|
+
onLoadMore={loadMore}
|
|
611
|
+
/>
|
|
612
|
+
)}
|
|
562
613
|
</>
|
|
563
614
|
)}
|
|
564
615
|
</>
|
|
@@ -48,6 +48,7 @@ describe("storage state", () => {
|
|
|
48
48
|
expect(state).toEqual({
|
|
49
49
|
namespaces: [],
|
|
50
50
|
entriesByPath: new Map(),
|
|
51
|
+
pageMetadataByPath: new Map(),
|
|
51
52
|
});
|
|
52
53
|
});
|
|
53
54
|
});
|
|
@@ -134,6 +135,49 @@ describe("storage state", () => {
|
|
|
134
135
|
expect(state.entriesByPath.get("my_s3::data/")).toEqual(entries);
|
|
135
136
|
});
|
|
136
137
|
|
|
138
|
+
it("should store next page tokens keyed by namespace and prefix", () => {
|
|
139
|
+
const entries = [makeEntry({ path: "a.txt" })];
|
|
140
|
+
|
|
141
|
+
actions.setEntries({
|
|
142
|
+
namespace: "my_s3",
|
|
143
|
+
prefix: "data/",
|
|
144
|
+
entries,
|
|
145
|
+
nextPageToken: "150",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(state.entriesByPath.get("my_s3::data/")).toEqual(entries);
|
|
149
|
+
expect(state.pageMetadataByPath.get("my_s3::data/")?.nextPageToken).toBe(
|
|
150
|
+
"150",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should append entries when requested", () => {
|
|
155
|
+
const firstPage = [makeEntry({ path: "a.txt" })];
|
|
156
|
+
const secondPage = [makeEntry({ path: "b.txt" })];
|
|
157
|
+
|
|
158
|
+
actions.setEntries({
|
|
159
|
+
namespace: "my_s3",
|
|
160
|
+
prefix: "data/",
|
|
161
|
+
entries: firstPage,
|
|
162
|
+
nextPageToken: "150",
|
|
163
|
+
});
|
|
164
|
+
actions.setEntries({
|
|
165
|
+
namespace: "my_s3",
|
|
166
|
+
prefix: "data/",
|
|
167
|
+
entries: secondPage,
|
|
168
|
+
nextPageToken: null,
|
|
169
|
+
append: true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(state.entriesByPath.get("my_s3::data/")).toEqual([
|
|
173
|
+
...firstPage,
|
|
174
|
+
...secondPage,
|
|
175
|
+
]);
|
|
176
|
+
expect(
|
|
177
|
+
state.pageMetadataByPath.get("my_s3::data/")?.nextPageToken,
|
|
178
|
+
).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
137
181
|
it("should use empty string for null prefix", () => {
|
|
138
182
|
const entries = [makeEntry({ path: "root.txt" })];
|
|
139
183
|
|
|
@@ -251,6 +295,11 @@ describe("storage state", () => {
|
|
|
251
295
|
expect(state.entriesByPath.get("my_s3::")).toBeUndefined();
|
|
252
296
|
expect(state.entriesByPath.get("my_s3::data/")).toBeUndefined();
|
|
253
297
|
expect(state.entriesByPath.get("my_s3::data/nested/")).toBeUndefined();
|
|
298
|
+
expect(state.pageMetadataByPath.get("my_s3::")).toBeUndefined();
|
|
299
|
+
expect(state.pageMetadataByPath.get("my_s3::data/")).toBeUndefined();
|
|
300
|
+
expect(
|
|
301
|
+
state.pageMetadataByPath.get("my_s3::data/nested/"),
|
|
302
|
+
).toBeUndefined();
|
|
254
303
|
});
|
|
255
304
|
|
|
256
305
|
it("should not affect entries from other namespaces", () => {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
4
|
import { createStore, Provider } from "jotai";
|
|
5
5
|
import type { ReactNode } from "react";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
storageAtom,
|
|
10
|
+
useStorageEntries,
|
|
11
|
+
useStoragePageFetcher,
|
|
12
|
+
} from "../state";
|
|
9
13
|
import type { StorageEntry, StorageState } from "../types";
|
|
10
14
|
|
|
11
15
|
const mockRequest = vi.fn();
|
|
@@ -175,13 +179,205 @@ describe("useStorageEntries", () => {
|
|
|
175
179
|
|
|
176
180
|
it("should store fetched entries in the atom", async () => {
|
|
177
181
|
const entries = [makeEntry({ path: "new.txt" })];
|
|
178
|
-
mockRequest.mockResolvedValue({
|
|
182
|
+
mockRequest.mockResolvedValue({
|
|
183
|
+
entries,
|
|
184
|
+
next_page_token: "150",
|
|
185
|
+
});
|
|
179
186
|
|
|
180
187
|
renderHook(() => useStorageEntries("ns", "sub/"), { wrapper });
|
|
181
188
|
|
|
182
189
|
await waitFor(() => {
|
|
183
190
|
const state = store.get(storageAtom);
|
|
184
191
|
expect(state.entriesByPath.get("ns::sub/")).toEqual(entries);
|
|
192
|
+
expect(state.pageMetadataByPath.get("ns::sub/")?.nextPageToken).toBe(
|
|
193
|
+
"150",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should load more entries when a next page token exists", async () => {
|
|
199
|
+
const firstPage = [makeEntry({ path: "a.txt" })];
|
|
200
|
+
const secondPage = [makeEntry({ path: "b.txt" })];
|
|
201
|
+
mockRequest
|
|
202
|
+
.mockResolvedValueOnce({
|
|
203
|
+
entries: firstPage,
|
|
204
|
+
next_page_token: "150",
|
|
205
|
+
})
|
|
206
|
+
.mockResolvedValueOnce({
|
|
207
|
+
entries: secondPage,
|
|
208
|
+
next_page_token: null,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
|
|
212
|
+
wrapper,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(result.current.entries).toEqual(firstPage);
|
|
217
|
+
});
|
|
218
|
+
expect(result.current.hasMore).toBe(true);
|
|
219
|
+
|
|
220
|
+
await act(async () => {
|
|
221
|
+
await result.current.loadMore();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
|
|
225
|
+
expect(result.current.hasMore).toBe(false);
|
|
226
|
+
expect(mockRequest).toHaveBeenLastCalledWith({
|
|
227
|
+
namespace: "ns",
|
|
228
|
+
prefix: "sub/",
|
|
229
|
+
limit: 150,
|
|
230
|
+
pageToken: "150",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("should load more entries when the next page token is an empty string", async () => {
|
|
235
|
+
const firstPage = [makeEntry({ path: "a.txt" })];
|
|
236
|
+
const secondPage = [makeEntry({ path: "b.txt" })];
|
|
237
|
+
mockRequest
|
|
238
|
+
.mockResolvedValueOnce({
|
|
239
|
+
entries: firstPage,
|
|
240
|
+
next_page_token: "",
|
|
241
|
+
})
|
|
242
|
+
.mockResolvedValueOnce({
|
|
243
|
+
entries: secondPage,
|
|
244
|
+
next_page_token: null,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
|
|
248
|
+
wrapper,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await waitFor(() => {
|
|
252
|
+
expect(result.current.entries).toEqual(firstPage);
|
|
253
|
+
});
|
|
254
|
+
expect(result.current.hasMore).toBe(true);
|
|
255
|
+
|
|
256
|
+
await act(async () => {
|
|
257
|
+
await result.current.loadMore();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
|
|
261
|
+
expect(result.current.hasMore).toBe(false);
|
|
262
|
+
expect(mockRequest).toHaveBeenLastCalledWith({
|
|
263
|
+
namespace: "ns",
|
|
264
|
+
prefix: "sub/",
|
|
265
|
+
limit: 150,
|
|
266
|
+
pageToken: "",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should ignore duplicate load more calls while a page is loading", async () => {
|
|
271
|
+
const firstPage = [makeEntry({ path: "a.txt" })];
|
|
272
|
+
const secondPage = [makeEntry({ path: "b.txt" })];
|
|
273
|
+
let resolveLoadMore!: (value: {
|
|
274
|
+
entries: StorageEntry[];
|
|
275
|
+
next_page_token: string | null;
|
|
276
|
+
}) => void;
|
|
277
|
+
const loadMorePromise = new Promise<{
|
|
278
|
+
entries: StorageEntry[];
|
|
279
|
+
next_page_token: string | null;
|
|
280
|
+
}>((resolve) => {
|
|
281
|
+
resolveLoadMore = resolve;
|
|
282
|
+
});
|
|
283
|
+
mockRequest
|
|
284
|
+
.mockResolvedValueOnce({
|
|
285
|
+
entries: firstPage,
|
|
286
|
+
next_page_token: "150",
|
|
287
|
+
})
|
|
288
|
+
.mockReturnValueOnce(loadMorePromise);
|
|
289
|
+
|
|
290
|
+
const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
|
|
291
|
+
wrapper,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(result.current.entries).toEqual(firstPage);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await act(async () => {
|
|
299
|
+
const firstLoad = result.current.loadMore();
|
|
300
|
+
const secondLoad = result.current.loadMore();
|
|
301
|
+
|
|
302
|
+
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
303
|
+
resolveLoadMore({
|
|
304
|
+
entries: secondPage,
|
|
305
|
+
next_page_token: null,
|
|
306
|
+
});
|
|
307
|
+
await Promise.all([firstLoad, secondLoad]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
|
|
311
|
+
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should fetch arbitrary storage pages", async () => {
|
|
315
|
+
const firstPage = [makeEntry({ path: "folder/a.txt" })];
|
|
316
|
+
const secondPage = [makeEntry({ path: "folder/b.txt" })];
|
|
317
|
+
mockRequest
|
|
318
|
+
.mockResolvedValueOnce({
|
|
319
|
+
entries: firstPage,
|
|
320
|
+
next_page_token: "150",
|
|
321
|
+
})
|
|
322
|
+
.mockResolvedValueOnce({
|
|
323
|
+
entries: secondPage,
|
|
324
|
+
next_page_token: null,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const { result } = renderHook(() => useStoragePageFetcher(), {
|
|
328
|
+
wrapper,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await act(async () => {
|
|
332
|
+
await result.current({
|
|
333
|
+
namespace: "ns",
|
|
334
|
+
prefix: "folder/",
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
await act(async () => {
|
|
338
|
+
await result.current({
|
|
339
|
+
namespace: "ns",
|
|
340
|
+
prefix: "folder/",
|
|
341
|
+
pageToken: "150",
|
|
342
|
+
append: true,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(store.get(storageAtom).entriesByPath.get("ns::folder/")).toEqual([
|
|
347
|
+
...firstPage,
|
|
348
|
+
...secondPage,
|
|
349
|
+
]);
|
|
350
|
+
expect(mockRequest).toHaveBeenLastCalledWith({
|
|
351
|
+
namespace: "ns",
|
|
352
|
+
prefix: "folder/",
|
|
353
|
+
limit: 150,
|
|
354
|
+
pageToken: "150",
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("should forward an empty string page token", async () => {
|
|
359
|
+
mockRequest.mockResolvedValue({
|
|
360
|
+
entries: [],
|
|
361
|
+
next_page_token: null,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const { result } = renderHook(() => useStoragePageFetcher(), {
|
|
365
|
+
wrapper,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
await result.current({
|
|
370
|
+
namespace: "ns",
|
|
371
|
+
prefix: "folder/",
|
|
372
|
+
pageToken: "",
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(mockRequest).toHaveBeenLastCalledWith({
|
|
377
|
+
namespace: "ns",
|
|
378
|
+
prefix: "folder/",
|
|
379
|
+
limit: 150,
|
|
380
|
+
pageToken: "",
|
|
185
381
|
});
|
|
186
382
|
});
|
|
187
383
|
});
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { atom, useAtomValue } from "jotai";
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
5
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
5
6
|
import { createReducerAndAtoms } from "@/utils/createReducer";
|
|
6
7
|
import type { NotificationMessageData } from "../kernel/messages";
|
|
7
8
|
import type { VariableName } from "../variables/types";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ListStorageEntries,
|
|
11
|
+
type StorageEntriesResult,
|
|
12
|
+
} from "./request-registry";
|
|
9
13
|
import type { StorageEntry, StorageState } from "./types";
|
|
10
14
|
import {
|
|
11
15
|
DEFAULT_FETCH_LIMIT,
|
|
@@ -18,6 +22,7 @@ function initialState(): StorageState {
|
|
|
18
22
|
return {
|
|
19
23
|
namespaces: [],
|
|
20
24
|
entriesByPath: new Map(),
|
|
25
|
+
pageMetadataByPath: new Map(),
|
|
21
26
|
};
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -48,23 +53,48 @@ const {
|
|
|
48
53
|
namespace: string;
|
|
49
54
|
prefix: string | null | undefined;
|
|
50
55
|
entries: StorageEntry[];
|
|
56
|
+
nextPageToken?: string | null;
|
|
57
|
+
append?: boolean;
|
|
51
58
|
},
|
|
52
59
|
) => {
|
|
53
60
|
const key = storagePathKey(opts.namespace, opts.prefix);
|
|
54
61
|
const entriesByPath = new Map(state.entriesByPath);
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
const entries =
|
|
63
|
+
opts.append && entriesByPath.has(key)
|
|
64
|
+
? [...(entriesByPath.get(key) ?? []), ...opts.entries]
|
|
65
|
+
: opts.entries;
|
|
66
|
+
entriesByPath.set(key, entries);
|
|
67
|
+
|
|
68
|
+
const pageMetadataByPath = new Map(state.pageMetadataByPath);
|
|
69
|
+
pageMetadataByPath.set(key, {
|
|
70
|
+
nextPageToken: opts.nextPageToken ?? null,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
entriesByPath,
|
|
75
|
+
pageMetadataByPath,
|
|
76
|
+
};
|
|
57
77
|
},
|
|
58
78
|
|
|
59
79
|
clearNamespaceCache: (state, namespace: string) => {
|
|
60
80
|
const entriesByPath = new Map(state.entriesByPath);
|
|
81
|
+
const pageMetadataByPath = new Map(state.pageMetadataByPath);
|
|
61
82
|
const prefix = storageNamespacePrefix(namespace);
|
|
62
83
|
for (const key of entriesByPath.keys()) {
|
|
63
84
|
if (key.startsWith(prefix)) {
|
|
64
85
|
entriesByPath.delete(key);
|
|
65
86
|
}
|
|
66
87
|
}
|
|
67
|
-
|
|
88
|
+
for (const key of pageMetadataByPath.keys()) {
|
|
89
|
+
if (key.startsWith(prefix)) {
|
|
90
|
+
pageMetadataByPath.delete(key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...state,
|
|
95
|
+
entriesByPath,
|
|
96
|
+
pageMetadataByPath,
|
|
97
|
+
};
|
|
68
98
|
},
|
|
69
99
|
|
|
70
100
|
filterFromVariables: (state, variableNames: VariableName[]) => {
|
|
@@ -93,38 +123,119 @@ export function useStorageActions() {
|
|
|
93
123
|
|
|
94
124
|
export { storageAtom };
|
|
95
125
|
|
|
126
|
+
async function fetchStorageEntriesPage({
|
|
127
|
+
namespace,
|
|
128
|
+
prefix,
|
|
129
|
+
pageToken,
|
|
130
|
+
append,
|
|
131
|
+
setEntries,
|
|
132
|
+
}: {
|
|
133
|
+
namespace: string;
|
|
134
|
+
prefix: string | null | undefined;
|
|
135
|
+
pageToken?: string | null;
|
|
136
|
+
append?: boolean;
|
|
137
|
+
setEntries: ReturnType<typeof useStorageActions>["setEntries"];
|
|
138
|
+
}): Promise<StorageEntriesResult> {
|
|
139
|
+
const result = await ListStorageEntries.request({
|
|
140
|
+
namespace,
|
|
141
|
+
prefix: prefix ?? ROOT_PATH,
|
|
142
|
+
limit: DEFAULT_FETCH_LIMIT,
|
|
143
|
+
...(pageToken != null ? { pageToken } : {}),
|
|
144
|
+
});
|
|
145
|
+
if (result.error) {
|
|
146
|
+
throw new Error(result.error);
|
|
147
|
+
}
|
|
148
|
+
setEntries({
|
|
149
|
+
namespace,
|
|
150
|
+
prefix,
|
|
151
|
+
entries: result.entries,
|
|
152
|
+
nextPageToken: result.next_page_token,
|
|
153
|
+
append,
|
|
154
|
+
});
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
96
158
|
/**
|
|
97
159
|
* Hook that fetches and caches storage entries for a given namespace/prefix.
|
|
98
160
|
* Entries are fetched on first access and cached in the store for subsequent renders.
|
|
99
161
|
*/
|
|
100
162
|
export function useStorageEntries(namespace: string, prefix?: string) {
|
|
101
|
-
const { entriesByPath } = useStorage();
|
|
163
|
+
const { entriesByPath, pageMetadataByPath } = useStorage();
|
|
102
164
|
const { setEntries } = useStorageActions();
|
|
103
|
-
const
|
|
165
|
+
const key = storagePathKey(namespace, prefix);
|
|
166
|
+
const cached = entriesByPath.get(key);
|
|
167
|
+
const metadata = pageMetadataByPath.get(key);
|
|
168
|
+
const nextPageToken = metadata?.nextPageToken ?? null;
|
|
169
|
+
const isLoadingMoreRef = useRef(false);
|
|
170
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
171
|
+
const [loadMoreError, setLoadMoreError] = useState<Error | undefined>();
|
|
104
172
|
|
|
105
173
|
const { isPending, error, refetch } = useAsyncData(async () => {
|
|
106
174
|
if (cached) {
|
|
107
175
|
return;
|
|
108
176
|
}
|
|
109
|
-
|
|
177
|
+
await fetchStorageEntriesPage({
|
|
110
178
|
namespace,
|
|
111
|
-
prefix
|
|
112
|
-
|
|
179
|
+
prefix,
|
|
180
|
+
setEntries,
|
|
113
181
|
});
|
|
114
|
-
if (result.error) {
|
|
115
|
-
throw new Error(result.error);
|
|
116
|
-
}
|
|
117
|
-
setEntries({ namespace, prefix, entries: result.entries });
|
|
118
182
|
}, [namespace, prefix, cached === undefined]);
|
|
119
183
|
|
|
184
|
+
const loadMore = useCallback(async () => {
|
|
185
|
+
if (nextPageToken == null || isLoadingMoreRef.current) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
isLoadingMoreRef.current = true;
|
|
190
|
+
setIsLoadingMore(true);
|
|
191
|
+
setLoadMoreError(undefined);
|
|
192
|
+
try {
|
|
193
|
+
await fetchStorageEntriesPage({
|
|
194
|
+
namespace,
|
|
195
|
+
prefix,
|
|
196
|
+
pageToken: nextPageToken,
|
|
197
|
+
append: true,
|
|
198
|
+
setEntries,
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
setLoadMoreError(
|
|
202
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
isLoadingMoreRef.current = false;
|
|
206
|
+
setIsLoadingMore(false);
|
|
207
|
+
}
|
|
208
|
+
}, [namespace, prefix, nextPageToken, setEntries]);
|
|
209
|
+
|
|
120
210
|
return {
|
|
121
211
|
entries: cached ?? [],
|
|
122
212
|
isPending: isPending && !cached,
|
|
123
213
|
error: cached ? undefined : error,
|
|
214
|
+
hasMore: nextPageToken !== null,
|
|
215
|
+
loadMore,
|
|
216
|
+
isLoadingMore,
|
|
217
|
+
loadMoreError,
|
|
124
218
|
refetch,
|
|
125
219
|
};
|
|
126
220
|
}
|
|
127
221
|
|
|
222
|
+
export function useStoragePageFetcher() {
|
|
223
|
+
const { setEntries } = useStorageActions();
|
|
224
|
+
return useCallback(
|
|
225
|
+
(opts: {
|
|
226
|
+
namespace: string;
|
|
227
|
+
prefix: string | null | undefined;
|
|
228
|
+
pageToken?: string | null;
|
|
229
|
+
append?: boolean;
|
|
230
|
+
}) =>
|
|
231
|
+
fetchStorageEntriesPage({
|
|
232
|
+
...opts,
|
|
233
|
+
setEntries,
|
|
234
|
+
}),
|
|
235
|
+
[setEntries],
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
128
239
|
export const exportedForTesting = {
|
|
129
240
|
reducer,
|
|
130
241
|
createActions,
|