@marimo-team/islands 0.23.10-dev5 → 0.23.10-dev51

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.
Files changed (183) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-Z3RP7Vmm.js} +31 -31
  2. package/dist/{ErrorBoundary-rULOrC_p.js → ErrorBoundary-DpbaKVv7.js} +1 -1
  3. package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-CAgFD4Kd.js} +20 -20
  4. package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
  5. package/dist/assets/worker-CC0Oul9k.js +73 -0
  6. package/dist/{chat-ui-1rLrACXD.js → chat-ui-BxwraqS-.js} +77 -77
  7. package/dist/{check-DTbrK0zt.js → check-BCaJeT-J.js} +1 -1
  8. package/dist/{chunk-5FQGJX7Z-BNjes6Yx.js → chunk-5FQGJX7Z-BbqSm5gU.js} +1227 -1222
  9. package/dist/{code-block-37QAKDTI-m92Yc8pv.js → code-block-37QAKDTI-O6IH6wAi.js} +1 -1
  10. package/dist/{code-visibility-Dek9JNl9.js → code-visibility-B5jSPI0-.js} +2166 -1292
  11. package/dist/{copy-BuQpJEzp.js → copy-UqRYxiOg.js} +33 -33
  12. package/dist/dist-7QfXoMdB.js +5 -0
  13. package/dist/{dist-DgnE8F-r.js → dist-A2846XWO.js} +1 -1
  14. package/dist/dist-BEXXyZig.js +5 -0
  15. package/dist/{dist-B3pZ0Ab6.js → dist-BR_gyG9L.js} +3 -3
  16. package/dist/{dist-CcXxepx6.js → dist-BSAt6RhH.js} +27 -27
  17. package/dist/{dist-Bde4a2kU.js → dist-BY018Paw.js} +8 -8
  18. package/dist/dist-BYj57OV4.js +5 -0
  19. package/dist/{dist-CUCNs1ja.js → dist-BaoDKvdy.js} +2 -2
  20. package/dist/{dist-Cy1WxgBD.js → dist-Bf7SHuNp.js} +5 -5
  21. package/dist/{dist-Bz_sYWbr.js → dist-Bk75fBZA.js} +2 -2
  22. package/dist/dist-BlSvQzNr.js +5 -0
  23. package/dist/{dist-C5VC_yzu.js → dist-BzEzfugY.js} +1 -1
  24. package/dist/dist-CCBlxAgS.js +8 -0
  25. package/dist/dist-CIDTVIUf.js +5 -0
  26. package/dist/{dist-CLUtPrdy.js → dist-CIYBwstr.js} +1 -1
  27. package/dist/{dist-BotSqB48.js → dist-C_Y3oV3C.js} +12 -12
  28. package/dist/{dist-BTfv03uy.js → dist-CcWX6tmx.js} +2 -2
  29. package/dist/{dist-BhM8gdSO.js → dist-CoXAujgg.js} +4 -4
  30. package/dist/{dist-4j4c7bjm.js → dist-CpxNdDkw.js} +3 -3
  31. package/dist/dist-CqQyhAM8.js +8 -0
  32. package/dist/dist-CwRu2Xzh.js +5 -0
  33. package/dist/{dist-BcuoonNH.js → dist-CxJDU6Bh.js} +9 -9
  34. package/dist/{dist-DxvORzUR.js → dist-D-W5ny5a.js} +8 -8
  35. package/dist/dist-D8CDTVgf.js +6 -0
  36. package/dist/dist-D8DNB0nO.js +8 -0
  37. package/dist/dist-DL6N_q-A.js +5 -0
  38. package/dist/{dist-BbbIBDiQ.js → dist-DMjWuVs8.js} +1 -1
  39. package/dist/dist-DOFbNV_b.js +8 -0
  40. package/dist/dist-DPrYzMY0.js +6 -0
  41. package/dist/{dist-h2c8sZvT.js → dist-DZORgqKY.js} +1 -1
  42. package/dist/{dist-B3P2fFpz.js → dist-DZo4nSS0.js} +14 -14
  43. package/dist/{dist-D4CewLk6.js → dist-Dax--nl9.js} +1 -1
  44. package/dist/{dist-DRfcqpxJ.js → dist-DgGbNavJ.js} +2 -2
  45. package/dist/{dist-C1BYNeCR.js → dist-Dk6PV_d3.js} +10 -10
  46. package/dist/{dist-fQ0ViXGs.js → dist-Dv_Y15yk.js} +107 -107
  47. package/dist/{dist-Bfwsv11D.js → dist-DyyjKEYf.js} +2 -2
  48. package/dist/{dist-p2qyWijU.js → dist-GZXUmt0b.js} +2 -2
  49. package/dist/{dist-CLJWPTX2.js → dist-LTU8Hdvn.js} +3 -3
  50. package/dist/{dist-DqAWR3CS.js → dist-M9Vag9Y0.js} +20 -20
  51. package/dist/{dist-DNdhYsgW.js → dist-U4F-tbMs.js} +79 -62
  52. package/dist/{dist-RqXTaiir.js → dist-abid3KgM.js} +11 -11
  53. package/dist/dist-cdmMjgsn.js +5 -0
  54. package/dist/dist-hT4QzYX-.js +1247 -0
  55. package/dist/{dist-luvabDEB.js → dist-t9Kf7xqC.js} +2 -2
  56. package/dist/{error-banner-5bz0L9hS.js → error-banner-CJXYJ6Sb.js} +11 -6
  57. package/dist/esm-BaH2eg5-.js +1171 -0
  58. package/dist/{esm-Duie8iU-.js → esm-ga2Bf3O2.js} +43 -43
  59. package/dist/{extends-BgdxCfYu.js → extends-D_hDsj6R.js} +4 -4
  60. package/dist/files/wasm-intro.py +11 -8
  61. package/dist/{formats-DHxc-FdY.js → formats-C4wO47tk.js} +1 -1
  62. package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-BPkCPs7L.js} +255 -255
  63. package/dist/{html-to-image-CnBdHeLh.js → html-to-image-DXSdAI_Y.js} +2338 -2279
  64. package/dist/{input-_2sjvfne.js → input-OdWHkobi.js} +263 -245
  65. package/dist/{label-LWtdw5i8.js → label-CC4ytI1X.js} +1 -1
  66. package/dist/main.js +7631 -7559
  67. package/dist/{mermaid-4DMBBIKO-DIdL224_.js → mermaid-4DMBBIKO-Eg3D4FVz.js} +1 -1
  68. package/dist/{mermaid-lXOw5Py9.js → mermaid-BotvIKg9.js} +4 -4
  69. package/dist/{process-output-vFgqkyUT.js → process-output-uozPQhBl.js} +3 -3
  70. package/dist/{reveal-component-C8AmnVfj.js → reveal-component-CBonF0Hv.js} +698 -620
  71. package/dist/{spec-B96zNUEA.js → spec-X7FwLJni.js} +4 -4
  72. package/dist/{strings-Bu3vlb6W.js → strings-J57tzLr3.js} +47 -46
  73. package/dist/style.css +1 -1
  74. package/dist/{toDate-x-WRDCH7.js → toDate-d8RCRrRd.js} +2 -2
  75. package/dist/{tooltip-C5FYOpQc.js → tooltip-DpcyNkQ2.js} +2 -2
  76. package/dist/{types-CVvp1fKr.js → types-ChtMFmZ2.js} +1 -1
  77. package/dist/{useAsyncData-iRgKDT5s.js → useAsyncData-PonK__yh.js} +1 -1
  78. package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-QB-3MpYr.js} +2 -2
  79. package/dist/{useDeepCompareMemoize-CkQ57VS2.js → useDeepCompareMemoize-D3NGWke6.js} +1 -1
  80. package/dist/{useLifecycle-BBO9PIph.js → useLifecycle-00mO3OSS.js} +2 -2
  81. package/dist/{useTheme-DHIrRQOe.js → useTheme-DEhDzATN.js} +1 -1
  82. package/dist/{vega-component-Dq-SH463.js → vega-component-DGPUhbDs.js} +8 -8
  83. package/dist/{zod-CoBiJ5v4.js → zod-aLSua2NL.js} +24 -23
  84. package/package.json +4 -3
  85. package/src/components/chat/chat-display.tsx +2 -2
  86. package/src/components/data-table/TableBottomBar.tsx +1 -15
  87. package/src/components/data-table/TableTopBar.tsx +8 -13
  88. package/src/components/data-table/__tests__/TableBottomBar.test.tsx +6 -12
  89. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +227 -0
  90. package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
  91. package/src/components/data-table/column-visibility-dropdown.tsx +204 -0
  92. package/src/components/data-table/data-table.tsx +1 -1
  93. package/src/components/data-table/filter-by-values-picker.tsx +39 -17
  94. package/src/components/data-table/filter-pills.tsx +1 -1
  95. package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
  96. package/src/components/data-table/hover-tooltip/content.ts +44 -0
  97. package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
  98. package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
  99. package/src/components/data-table/renderers.tsx +27 -43
  100. package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
  101. package/src/components/datasources/datasources.tsx +92 -3
  102. package/src/components/editor/cell/code/language-toggle.tsx +7 -1
  103. package/src/components/editor/chrome/wrapper/app-chrome.tsx +97 -52
  104. package/src/components/editor/chrome/wrapper/lazy-panels.ts +91 -0
  105. package/src/components/editor/chrome/wrapper/sidebar.tsx +2 -0
  106. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +9 -2
  107. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +62 -27
  108. package/src/components/editor/connections/storage/add-storage-form.tsx +5 -1
  109. package/src/components/editor/connections/storage/as-code.ts +20 -6
  110. package/src/components/editor/documentation.css +35 -0
  111. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  112. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  113. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
  114. package/src/components/editor/renderers/slides-layout/types.ts +1 -0
  115. package/src/components/markdown/__tests__/markdown-renderer.test.tsx +43 -0
  116. package/src/components/markdown/markdown-renderer.tsx +24 -1
  117. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  118. package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
  119. package/src/components/slides/minimap.tsx +127 -10
  120. package/src/components/slides/reveal-component.tsx +287 -61
  121. package/src/components/slides/slide-cell-view.tsx +26 -2
  122. package/src/components/slides/slide-form.tsx +26 -4
  123. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  124. package/src/components/storage/storage-inspector.tsx +115 -44
  125. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  126. package/src/components/ui/combobox.tsx +51 -32
  127. package/src/components/ui/number-field.tsx +5 -1
  128. package/src/components/ui/reorderable-list.tsx +13 -0
  129. package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
  130. package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
  131. package/src/components/ui/select-core/index.ts +16 -0
  132. package/src/components/ui/select-core/option-row.tsx +33 -0
  133. package/src/components/ui/select-core/render-slot.ts +20 -0
  134. package/src/components/ui/select-core/select-list.tsx +248 -0
  135. package/src/components/ui/select-core/types.ts +44 -0
  136. package/src/components/ui/select-core/use-select-list.ts +347 -0
  137. package/src/components/ui/select-core/utils.ts +121 -0
  138. package/src/components/ui/use-toast.ts +33 -13
  139. package/src/core/codemirror/language/languages/python.ts +2 -0
  140. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  141. package/src/core/codemirror/lsp/__tests__/markdown-renderer.test.ts +41 -0
  142. package/src/core/codemirror/lsp/markdown-renderer.ts +59 -0
  143. package/src/core/datasets/data-source-connections.ts +2 -0
  144. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  145. package/src/core/network/requests-static.ts +14 -10
  146. package/src/core/storage/__tests__/state.test.ts +49 -0
  147. package/src/core/storage/__tests__/useStorageEntries.test.tsx +199 -3
  148. package/src/core/storage/state.ts +124 -13
  149. package/src/core/storage/types.ts +6 -0
  150. package/src/core/wasm/worker/bootstrap.ts +12 -4
  151. package/src/plugins/core/__test__/registerReactComponent.test.ts +96 -0
  152. package/src/plugins/core/registerReactComponent.tsx +42 -10
  153. package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
  154. package/src/plugins/impl/SearchableSelect.tsx +16 -97
  155. package/src/plugins/impl/SliderPlugin.tsx +132 -18
  156. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
  157. package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
  158. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +278 -8
  159. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  160. package/src/utils/__tests__/numbers.test.ts +20 -0
  161. package/src/utils/errors.ts +9 -0
  162. package/src/utils/lazy.ts +6 -1
  163. package/src/utils/numbers.ts +27 -0
  164. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  165. package/dist/assets/worker-ip3AI_sN.js +0 -73
  166. package/dist/dist-0Fif7jnk.js +0 -5
  167. package/dist/dist-B5h_9sHB.js +0 -6
  168. package/dist/dist-B9M6R5ye.js +0 -5
  169. package/dist/dist-BCt3tnck.js +0 -8
  170. package/dist/dist-BUIJwMwn.js +0 -8
  171. package/dist/dist-BpquMd3k.js +0 -5
  172. package/dist/dist-BzJsqYfz.js +0 -5
  173. package/dist/dist-CA5ELXAf.js +0 -6
  174. package/dist/dist-CLBRs6Uv.js +0 -5
  175. package/dist/dist-CStVCMbq.js +0 -5
  176. package/dist/dist-CZRIEY3Y.js +0 -8
  177. package/dist/dist-CuUHbFD0.js +0 -5
  178. package/dist/dist-DV7Iabxb.js +0 -8
  179. package/dist/dist-DhHh0jLg.js +0 -1247
  180. package/dist/dist-DuEeHMvL.js +0 -5
  181. package/dist/esm-BfhQmZjp.js +0 -1171
  182. package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
  183. /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
@@ -0,0 +1,53 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { describe, expect, it } from "vitest";
3
+ import type { StorageEntry } from "@/core/storage/types";
4
+ import { storageEntryKey } from "../storage-inspector";
5
+
6
+ function makeEntry(
7
+ overrides: Partial<StorageEntry> & { path: string },
8
+ ): StorageEntry {
9
+ return {
10
+ kind: overrides.kind ?? "file",
11
+ lastModified: overrides.lastModified ?? null,
12
+ metadata: overrides.metadata ?? {},
13
+ path: overrides.path,
14
+ size: overrides.size ?? 0,
15
+ };
16
+ }
17
+
18
+ describe("storageEntryKey", () => {
19
+ it("prefers the backend id when present (e.g. Google Drive)", () => {
20
+ const entry = makeEntry({
21
+ path: "Data Resume.pdf",
22
+ metadata: { id: "drive-file-id-123" },
23
+ });
24
+ // Two files can share a path on Drive; the id keeps keys unique.
25
+ expect(storageEntryKey(entry, 0)).toBe("drive-file-id-123");
26
+ expect(storageEntryKey(entry, 4)).toBe("drive-file-id-123");
27
+ });
28
+
29
+ it("falls back to path + index when there is no id", () => {
30
+ const entry = makeEntry({ path: "Data Resume.pdf" });
31
+ expect(storageEntryKey(entry, 0)).toBe("Data Resume.pdf::0");
32
+ expect(storageEntryKey(entry, 4)).toBe("Data Resume.pdf::4");
33
+ });
34
+
35
+ it("keeps duplicate-path entries unique via the index fallback", () => {
36
+ const entries = [
37
+ makeEntry({ path: "Data Resume.pdf" }),
38
+ makeEntry({ path: "Data Resume.pdf" }),
39
+ makeEntry({ path: "Data Resume.pdf" }),
40
+ ];
41
+ const keys = entries.map((entry, index) => storageEntryKey(entry, index));
42
+ expect(new Set(keys).size).toBe(entries.length);
43
+ });
44
+
45
+ it("ignores a non-string or empty id", () => {
46
+ expect(
47
+ storageEntryKey(makeEntry({ path: "a.pdf", metadata: { id: "" } }), 2),
48
+ ).toBe("a.pdf::2");
49
+ expect(
50
+ storageEntryKey(makeEntry({ path: "a.pdf", metadata: { id: 5 } }), 2),
51
+ ).toBe("a.pdf::2");
52
+ });
53
+ });
@@ -94,15 +94,31 @@ function displayName(path: string): string {
94
94
  return parts[parts.length - 1] || trimmed;
95
95
  }
96
96
 
97
+ /**
98
+ * Stable, unique identity for an entry row. Prefer the
99
+ * backend's stable id when present and fall back to the list index
100
+ */
101
+ export function storageEntryKey(entry: StorageEntry, index: number): string {
102
+ const id = entry.metadata?.id;
103
+ if (typeof id === "string" && id.length > 0) {
104
+ return id;
105
+ }
106
+ return `${entry.path}::${index}`;
107
+ }
108
+
109
+ interface SearchContext {
110
+ namespace: string;
111
+ searchValue: string;
112
+ entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>;
113
+ }
114
+
97
115
  /**
98
116
  * Recursively check whether an entry (or any of its loaded descendants)
99
117
  * matches the search query.
100
118
  */
101
119
  function entryMatchesSearch(
102
120
  entry: StorageEntry,
103
- namespace: string,
104
- searchValue: string,
105
- entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>,
121
+ { namespace, searchValue, entriesByPath }: SearchContext,
106
122
  ): boolean {
107
123
  const query = searchValue.toLowerCase();
108
124
 
@@ -115,7 +131,7 @@ function entryMatchesSearch(
115
131
  const children = entriesByPath.get(storagePathKey(namespace, entry.path));
116
132
  if (children) {
117
133
  return children.some((child) =>
118
- entryMatchesSearch(child, namespace, searchValue, entriesByPath),
134
+ entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
119
135
  );
120
136
  }
121
137
  }
@@ -129,18 +145,41 @@ function entryMatchesSearch(
129
145
  */
130
146
  function filterEntries(
131
147
  entries: StorageEntry[],
132
- namespace: string,
133
- searchValue: string,
134
- entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>,
148
+ context: SearchContext,
135
149
  ): StorageEntry[] {
136
- if (!searchValue.trim()) {
150
+ if (!context.searchValue.trim()) {
137
151
  return entries;
138
152
  }
139
- return entries.filter((entry) =>
140
- entryMatchesSearch(entry, namespace, searchValue, entriesByPath),
141
- );
153
+ return entries.filter((entry) => entryMatchesSearch(entry, context));
142
154
  }
143
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
+
144
183
  /**
145
184
  * Lazily loaded children of a directory entry.
146
185
  * Caches fetched entries in the Jotai store so re-expanding doesn't re-fetch.
@@ -171,6 +210,10 @@ const StorageEntryChildren: React.FC<{
171
210
  entries: children,
172
211
  isPending,
173
212
  error,
213
+ hasMore,
214
+ loadMore,
215
+ isLoadingMore,
216
+ loadMoreError,
174
217
  } = useStorageEntries(namespace, prefix);
175
218
 
176
219
  if (isPending) {
@@ -204,35 +247,47 @@ const StorageEntryChildren: React.FC<{
204
247
  );
205
248
  }
206
249
 
207
- const filtered = filterEntries(
208
- children,
250
+ const filtered = filterEntries(children, {
209
251
  namespace,
210
252
  searchValue,
211
253
  entriesByPath,
212
- );
254
+ });
213
255
 
214
256
  return (
215
257
  <>
216
- {filtered.map((child) => (
217
- <StorageEntryRow
218
- key={child.path}
219
- entry={child}
220
- namespace={namespace}
221
- protocol={protocol}
222
- rootPath={rootPath}
223
- backendType={backendType}
258
+ {filtered.map((child) => {
259
+ const rowKey = storageEntryKey(child, children.indexOf(child));
260
+ return (
261
+ <StorageEntryRow
262
+ key={rowKey}
263
+ rowKey={rowKey}
264
+ entry={child}
265
+ namespace={namespace}
266
+ protocol={protocol}
267
+ rootPath={rootPath}
268
+ backendType={backendType}
269
+ depth={depth}
270
+ locale={locale}
271
+ searchValue={searchValue}
272
+ onOpenFile={onOpenFile}
273
+ />
274
+ );
275
+ })}
276
+ {hasMore && (
277
+ <LoadMoreStorageEntries
224
278
  depth={depth}
225
- locale={locale}
226
- searchValue={searchValue}
227
- onOpenFile={onOpenFile}
279
+ isLoading={isLoadingMore}
280
+ error={loadMoreError}
281
+ onLoadMore={loadMore}
228
282
  />
229
- ))}
283
+ )}
230
284
  </>
231
285
  );
232
286
  };
233
287
 
234
288
  const StorageEntryRow: React.FC<{
235
289
  entry: StorageEntry;
290
+ rowKey: string;
236
291
  namespace: string;
237
292
  protocol: string;
238
293
  rootPath: string;
@@ -243,6 +298,7 @@ const StorageEntryRow: React.FC<{
243
298
  onOpenFile: (info: OpenFileInfo) => void;
244
299
  }> = ({
245
300
  entry,
301
+ rowKey,
246
302
  namespace,
247
303
  protocol,
248
304
  rootPath,
@@ -271,7 +327,7 @@ const StorageEntryRow: React.FC<{
271
327
  !!entriesByPath
272
328
  .get(storagePathKey(namespace, entry.path))
273
329
  ?.some((child) =>
274
- entryMatchesSearch(child, namespace, searchValue, entriesByPath),
330
+ entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
275
331
  );
276
332
 
277
333
  // Folder is shown expanded by manual toggle OR by search auto-expand
@@ -312,7 +368,7 @@ const StorageEntryRow: React.FC<{
312
368
  isDir && "font-medium",
313
369
  )}
314
370
  style={indentStyle(depth)}
315
- value={`${namespace}:${entry.path}`}
371
+ value={`${namespace}:${rowKey}`}
316
372
  onSelect={() => {
317
373
  if (isDir) {
318
374
  setIsExpanded(!effectiveExpanded);
@@ -444,6 +500,10 @@ const StorageNamespaceSection: React.FC<{
444
500
  entries: fetchedEntries,
445
501
  isPending,
446
502
  error,
503
+ hasMore,
504
+ loadMore,
505
+ isLoadingMore,
506
+ loadMoreError,
447
507
  refetch,
448
508
  } = useStorageEntries(namespaceName);
449
509
 
@@ -458,12 +518,11 @@ const StorageNamespaceSection: React.FC<{
458
518
 
459
519
  // While loading, fall back to initial entries from the namespace notification
460
520
  const entries = isPending ? namespace.storageEntries : fetchedEntries;
461
- const filtered = filterEntries(
462
- entries,
463
- namespaceName,
521
+ const filtered = filterEntries(entries, {
522
+ namespace: namespaceName,
464
523
  searchValue,
465
524
  entriesByPath,
466
- );
525
+ });
467
526
 
468
527
  return (
469
528
  <>
@@ -525,20 +584,32 @@ const StorageNamespaceSection: React.FC<{
525
584
  No matches
526
585
  </div>
527
586
  )}
528
- {filtered.map((entry) => (
529
- <StorageEntryRow
530
- key={entry.path}
531
- entry={entry}
532
- namespace={namespaceName}
533
- protocol={namespace.protocol}
534
- rootPath={namespace.rootPath}
535
- backendType={namespace.backendType}
587
+ {filtered.map((entry) => {
588
+ const rowKey = storageEntryKey(entry, entries.indexOf(entry));
589
+ return (
590
+ <StorageEntryRow
591
+ key={rowKey}
592
+ rowKey={rowKey}
593
+ entry={entry}
594
+ namespace={namespaceName}
595
+ protocol={namespace.protocol}
596
+ rootPath={namespace.rootPath}
597
+ backendType={namespace.backendType}
598
+ depth={1}
599
+ locale={locale}
600
+ searchValue={searchValue}
601
+ onOpenFile={onOpenFile}
602
+ />
603
+ );
604
+ })}
605
+ {hasMore && (
606
+ <LoadMoreStorageEntries
536
607
  depth={1}
537
- locale={locale}
538
- searchValue={searchValue}
539
- onOpenFile={onOpenFile}
608
+ isLoading={isLoadingMore}
609
+ error={loadMoreError}
610
+ onLoadMore={loadMore}
540
611
  />
541
- ))}
612
+ )}
542
613
  </>
543
614
  )}
544
615
  </>
@@ -0,0 +1,75 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { toast, useToast } from "@/components/ui/use-toast";
5
+
6
+ describe("toast once", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ const { result } = renderHook(() => useToast());
13
+ act(() => {
14
+ result.current.dismiss();
15
+ vi.runAllTimers();
16
+ });
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("shows a once-toast a single time per session, even after removal", () => {
21
+ const { result } = renderHook(() => useToast());
22
+
23
+ act(() => {
24
+ toast({ id: "static-notebook", once: true, title: "Static notebook" });
25
+ });
26
+ expect(result.current.toasts).toHaveLength(1);
27
+
28
+ act(() => {
29
+ result.current.dismiss("static-notebook");
30
+ vi.advanceTimersByTime(10_000);
31
+ });
32
+ expect(result.current.toasts).toHaveLength(0);
33
+
34
+ act(() => {
35
+ toast({ id: "static-notebook", once: true, title: "Static notebook" });
36
+ });
37
+ expect(result.current.toasts).toHaveLength(0);
38
+ });
39
+
40
+ it("does not suppress normal (non-once) toasts", () => {
41
+ const { result } = renderHook(() => useToast());
42
+
43
+ act(() => {
44
+ toast({ id: "normal", title: "First" });
45
+ });
46
+ act(() => {
47
+ result.current.dismiss("normal");
48
+ vi.advanceTimersByTime(10_000);
49
+ });
50
+ expect(result.current.toasts).toHaveLength(0);
51
+
52
+ act(() => {
53
+ toast({ id: "normal", title: "First" });
54
+ });
55
+ expect(result.current.toasts).toHaveLength(1);
56
+ });
57
+
58
+ it("does not dedupe a once-toast without a stable id", () => {
59
+ const { result } = renderHook(() => useToast());
60
+
61
+ act(() => {
62
+ toast({ once: true, title: "No id" });
63
+ });
64
+ act(() => {
65
+ result.current.dismiss();
66
+ vi.advanceTimersByTime(10_000);
67
+ });
68
+ expect(result.current.toasts).toHaveLength(0);
69
+
70
+ act(() => {
71
+ toast({ once: true, title: "No id" });
72
+ });
73
+ expect(result.current.toasts).toHaveLength(1);
74
+ });
75
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
4
  import { Check, ChevronDownIcon, XCircle } from "lucide-react";
5
- import React, { createContext } from "react";
5
+ import React, { createContext, useCallback, useMemo } from "react";
6
6
  import { cn } from "../../utils/cn";
7
7
  import { Functions } from "../../utils/functions";
8
8
  import { Badge } from "./badge";
@@ -40,6 +40,8 @@ interface ComboboxCommonProps<TValue> {
40
40
  id?: string;
41
41
  keepPopoverOpenOnSelect?: boolean;
42
42
  disabled?: boolean;
43
+ /** Override the trigger contents with a node (e.g. a compact chip summary). */
44
+ renderValue?: (value: TValue[] | TValue | null) => React.ReactNode;
43
45
  }
44
46
 
45
47
  type ComboboxFilterProps =
@@ -97,6 +99,7 @@ export const Combobox = <TValue,>({
97
99
  keepPopoverOpenOnSelect,
98
100
  id,
99
101
  disabled = false,
102
+ renderValue,
100
103
  ...rest
101
104
  }: ComboboxProps<TValue>) => {
102
105
  const [open = false, setOpen] = useControllableState({
@@ -112,39 +115,45 @@ export const Combobox = <TValue,>({
112
115
  },
113
116
  });
114
117
 
115
- const isSelected = (selectedValue: unknown) => {
116
- if (Array.isArray(value)) {
117
- return value.includes(selectedValue as TValue);
118
- }
119
- return value === selectedValue;
120
- };
118
+ const isSelected = useCallback(
119
+ (selectedValue: unknown) => {
120
+ if (Array.isArray(value)) {
121
+ return value.includes(selectedValue as TValue);
122
+ }
123
+ return value === selectedValue;
124
+ },
125
+ [value],
126
+ );
121
127
 
122
- const handleSelect = (selectedValue: unknown) => {
123
- let newValue: TValue | TValue[] | null = selectedValue as TValue;
128
+ const handleSelect = useCallback(
129
+ (selectedValue: unknown) => {
130
+ let newValue: TValue | TValue[] | null = selectedValue as TValue;
124
131
 
125
- if (multiple) {
126
- if (Array.isArray(value)) {
127
- if (value.includes(newValue)) {
128
- const newArr = value.filter((val) => val !== selectedValue);
129
- newValue = newArr.length > 0 ? newArr : [];
132
+ if (multiple) {
133
+ if (Array.isArray(value)) {
134
+ if (value.includes(newValue)) {
135
+ const newArr = value.filter((val) => val !== selectedValue);
136
+ newValue = newArr.length > 0 ? newArr : [];
137
+ } else {
138
+ newValue = [...value, newValue];
139
+ }
130
140
  } else {
131
- newValue = [...value, newValue];
141
+ newValue = [newValue];
132
142
  }
133
- } else {
134
- newValue = [newValue];
143
+ } else if (value === selectedValue) {
144
+ newValue = null;
135
145
  }
136
- } else if (value === selectedValue) {
137
- newValue = null;
138
- }
139
146
 
140
- setValue(newValue);
141
- const keepOpen = keepPopoverOpenOnSelect ?? multiple;
142
- if (!keepOpen) {
143
- setOpen(false);
144
- }
145
- };
147
+ setValue(newValue);
148
+ const keepOpen = keepPopoverOpenOnSelect ?? multiple;
149
+ if (!keepOpen) {
150
+ setOpen(false);
151
+ }
152
+ },
153
+ [keepPopoverOpenOnSelect, multiple, value, setValue, setOpen],
154
+ );
146
155
 
147
- const renderValue = (): string => {
156
+ const renderValueLabel = (): string => {
148
157
  // If we show chips, we don't want to change the placeholder
149
158
  if (multiple && chips && placeholder) {
150
159
  return placeholder;
@@ -168,6 +177,14 @@ export const Combobox = <TValue,>({
168
177
  return placeholder ?? "--";
169
178
  };
170
179
 
180
+ const comboboxContextValue: ComboboxContextValue = useMemo(
181
+ () => ({
182
+ isSelected,
183
+ onSelect: handleSelect,
184
+ }),
185
+ [isSelected, handleSelect],
186
+ );
187
+
171
188
  return (
172
189
  <div className={cn("relative")} {...rest}>
173
190
  <Popover
@@ -191,7 +208,9 @@ export const Combobox = <TValue,>({
191
208
  aria-expanded={open}
192
209
  aria-disabled={disabled}
193
210
  >
194
- <span className="truncate flex-1 min-w-0">{renderValue()}</span>
211
+ <span className="truncate flex-1 min-w-0">
212
+ {renderValue ? renderValue(value ?? null) : renderValueLabel()}
213
+ </span>
195
214
  <ChevronDownIcon className="ml-3 w-4 h-4 opacity-50 shrink-0" />
196
215
  </button>
197
216
  </PopoverTrigger>
@@ -209,7 +228,7 @@ export const Combobox = <TValue,>({
209
228
  />
210
229
  <CommandList className="max-h-60 py-.5">
211
230
  <CommandEmpty>{emptyState}</CommandEmpty>
212
- <ComboboxContext value={{ isSelected, onSelect: handleSelect }}>
231
+ <ComboboxContext value={comboboxContextValue}>
213
232
  {children}
214
233
  </ComboboxContext>
215
234
  </CommandList>
@@ -277,12 +296,14 @@ export const ComboboxItem = React.forwardRef(
277
296
  ? value.value
278
297
  : String(value);
279
298
  const context = React.use(ComboboxContext);
299
+ const isOptionSelected = context.isSelected(value);
280
300
 
281
301
  return (
282
302
  <CommandItem
283
303
  ref={ref}
284
304
  className={cn("pl-6 m-1 py-1", className)}
285
305
  role="option"
306
+ aria-selected={isOptionSelected}
286
307
  value={valueAsString}
287
308
  disabled={disabled}
288
309
  onSelect={() => {
@@ -290,9 +311,7 @@ export const ComboboxItem = React.forwardRef(
290
311
  onSelect?.(value);
291
312
  }}
292
313
  >
293
- {context.isSelected(value) && (
294
- <Check className="absolute left-1 h-4 w-4" />
295
- )}
314
+ {isOptionSelected && <Check className="absolute left-1 h-4 w-4" />}
296
315
  {children}
297
316
  </CommandItem>
298
317
  );
@@ -25,7 +25,10 @@ export interface NumberFieldProps extends AriaNumberFieldProps {
25
25
  }
26
26
 
27
27
  export const NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>(
28
- ({ placeholder, variant = "default", onInputText, ...props }, ref) => {
28
+ (
29
+ { placeholder, variant = "default", onInputText, formatOptions, ...props },
30
+ ref,
31
+ ) => {
29
32
  const { locale } = useLocale();
30
33
  return (
31
34
  <AriaNumberField
@@ -33,6 +36,7 @@ export const NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>(
33
36
  formatOptions={{
34
37
  minimumFractionDigits: 0,
35
38
  maximumFractionDigits: maxFractionalDigits(locale),
39
+ ...formatOptions,
36
40
  }}
37
41
  >
38
42
  <div
@@ -57,6 +57,12 @@ export interface ReorderableListProps<T> {
57
57
  * Callback when an item is clicked
58
58
  */
59
59
  onAction?: (item: T) => void;
60
+ /**
61
+ * Fired when an item is hovered or focused. Useful for preloading the
62
+ * resource the item points to before it is activated. Attached to the
63
+ * focusable list item so it works for both pointer and keyboard users.
64
+ */
65
+ onItemPreloadHint?: (item: T) => void;
60
66
  /**
61
67
  * All available items that can be added to the list
62
68
  */
@@ -142,6 +148,7 @@ export const ReorderableList = <T extends object>({
142
148
  ariaLabel = "Reorderable list",
143
149
  className,
144
150
  crossListDrag,
151
+ onItemPreloadHint,
145
152
  }: ReorderableListProps<T>) => {
146
153
  const mimeType = crossListDrag
147
154
  ? getDragMimeType(crossListDrag.dragType)
@@ -294,6 +301,12 @@ export const ReorderableList = <T extends object>({
294
301
  key={getKey(item)}
295
302
  id={getKey(item)}
296
303
  className="active:cursor-grabbing data-[dragging]:opacity-60 outline-none"
304
+ onHoverStart={
305
+ onItemPreloadHint ? () => onItemPreloadHint(item) : undefined
306
+ }
307
+ onFocus={
308
+ onItemPreloadHint ? () => onItemPreloadHint(item) : undefined
309
+ }
297
310
  >
298
311
  {renderItem(item)}
299
312
  </ListBoxItem>