@marimo-team/islands 0.23.10-dev2 → 0.23.10-dev21

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 (52) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DdeG-Hi-.js} +23 -23
  2. package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-CiES2a2h.js} +2 -2
  3. package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
  4. package/dist/assets/worker-CC0Oul9k.js +73 -0
  5. package/dist/{chat-ui-ar37brtL.js → chat-ui-BTobdMRF.js} +61 -61
  6. package/dist/{code-visibility-B88v1No3.js → code-visibility-Cu6I0RUK.js} +1212 -1046
  7. package/dist/{copy-BuQpJEzp.js → copy-5jQ_kGE1.js} +32 -32
  8. package/dist/{esm-BfhQmZjp.js → esm-CCuYCd3R.js} +1 -1
  9. package/dist/{extends-BgdxCfYu.js → extends-CkydH1Q5.js} +1 -1
  10. package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-CRvL2R9l.js} +7 -7
  11. package/dist/{html-to-image-Cp8O1OWB.js → html-to-image-CjsdUYrb.js} +2258 -2238
  12. package/dist/{input-_2sjvfne.js → input-DVkbXbIX.js} +183 -181
  13. package/dist/main.js +1565 -1363
  14. package/dist/{process-output-CaUUWhh8.js → process-output-CI8a-CUx.js} +2 -2
  15. package/dist/{reveal-component-CfFoUPFg.js → reveal-component-EOadhR-6.js} +5 -5
  16. package/dist/{spec-B96zNUEA.js → spec-DMRQmLOc.js} +2 -2
  17. package/dist/{strings-Bu3vlb6W.js → strings-GCJA9n6d.js} +25 -24
  18. package/dist/style.css +1 -1
  19. package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-BRcO_TGJ.js} +1 -1
  20. package/package.json +3 -3
  21. package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
  22. package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
  23. package/src/components/data-table/hover-tooltip/content.ts +44 -0
  24. package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
  25. package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
  26. package/src/components/data-table/renderers.tsx +27 -43
  27. package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
  28. package/src/components/datasources/datasources.tsx +92 -3
  29. package/src/components/editor/cell/cell-context-menu.tsx +15 -2
  30. package/src/components/editor/documentation.css +16 -0
  31. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  32. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  33. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  34. package/src/components/slides/minimap.tsx +127 -10
  35. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  36. package/src/components/storage/storage-inspector.tsx +68 -48
  37. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  38. package/src/components/ui/use-toast.ts +33 -13
  39. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
  40. package/src/core/cells/__tests__/cell.test.ts +29 -2
  41. package/src/core/cells/cell.ts +5 -1
  42. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +37 -0
  43. package/src/core/codemirror/go-to-definition/commands.ts +17 -9
  44. package/src/core/codemirror/go-to-definition/utils.ts +1 -0
  45. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  46. package/src/core/datasets/data-source-connections.ts +2 -0
  47. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  48. package/src/core/network/requests-static.ts +14 -10
  49. package/src/core/wasm/worker/bootstrap.ts +12 -4
  50. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  51. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  52. package/dist/assets/worker-ip3AI_sN.js +0 -73
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { useDeleteCellCallback } from "@/components/editor/cell/useDeleteCell";
3
4
  import { useCellActions, useCellIds } from "@/core/cells/cells";
4
5
  import type { CellId } from "@/core/cells/ids";
5
6
  import type { CellColumnId } from "@/utils/id-tree";
@@ -31,9 +32,17 @@ import {
31
32
  } from "@dnd-kit/sortable";
32
33
  import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
33
34
  import { cn } from "@/utils/cn";
35
+ import { Events } from "@/utils/events";
34
36
  import { Slide } from "./slide";
35
- import { InfoIcon, type LucideIcon } from "lucide-react";
37
+ import { InfoIcon, type LucideIcon, PlusIcon, Trash2Icon } from "lucide-react";
36
38
  import { Tooltip } from "@/components/ui/tooltip";
39
+ import {
40
+ ContextMenu,
41
+ ContextMenuContent,
42
+ ContextMenuItem,
43
+ ContextMenuSeparator,
44
+ ContextMenuTrigger,
45
+ } from "@/components/ui/context-menu";
37
46
  import { Logger } from "@/utils/Logger";
38
47
  import { SLIDE_TYPE_OPTIONS_BY_VALUE } from "./slide-form";
39
48
 
@@ -71,7 +80,7 @@ interface SlideThumbnailCardProps extends React.HTMLAttributes<HTMLDivElement> {
71
80
  ref?: React.Ref<HTMLDivElement>;
72
81
  }
73
82
 
74
- interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
83
+ interface SlideThumbnailRowProps extends React.HTMLAttributes<HTMLDivElement> {
75
84
  cell: SlideCell;
76
85
  dimensions: ThumbnailDimensions;
77
86
  isActiveSlide?: boolean;
@@ -80,7 +89,10 @@ interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonEl
80
89
  isVisible?: boolean;
81
90
  isNoOutput?: boolean;
82
91
  slideType?: SlideType;
83
- ref?: React.Ref<HTMLButtonElement>;
92
+ onInsertAbove?: () => void;
93
+ onInsertBelow?: () => void;
94
+ onDelete?: () => void;
95
+ ref?: React.Ref<HTMLDivElement>;
84
96
  }
85
97
 
86
98
  interface SlidesMinimapProps {
@@ -216,7 +228,8 @@ export const SlidesMinimap = ({
216
228
  onSlideClick,
217
229
  }: SlidesMinimapProps) => {
218
230
  const cellIds = useCellIds();
219
- const { moveCellToIndex } = useCellActions();
231
+ const { moveCellToIndex, createNewCell } = useCellActions();
232
+ const deleteCell = useDeleteCellCallback();
220
233
  const containerRef = useRef<HTMLDivElement>(null);
221
234
  const visibleIds = useVisibleCellIds(containerRef);
222
235
  const [activeId, setActiveId] = useState<CellId | null>(null);
@@ -225,6 +238,10 @@ export const SlidesMinimap = ({
225
238
  );
226
239
  const dimensions = computeThumbnailDimensions(thumbnailWidth);
227
240
 
241
+ const insertCell = (cellId: CellId, before: boolean) => {
242
+ createNewCell({ cellId, before, code: "", autoFocus: false });
243
+ };
244
+
228
245
  useEffect(() => {
229
246
  if (!activeCellId || !containerRef.current) {
230
247
  return;
@@ -310,6 +327,11 @@ export const SlidesMinimap = ({
310
327
  slideTypes,
311
328
  skippedIds,
312
329
  })}
330
+ onInsertAbove={
331
+ index === 0 ? () => insertCell(cell.id, true) : undefined
332
+ }
333
+ onInsertBelow={() => insertCell(cell.id, false)}
334
+ onDelete={() => deleteCell({ cellId: cell.id })}
313
335
  onClick={() => onSlideClick(index)}
314
336
  />
315
337
  ))}
@@ -353,6 +375,11 @@ export const SlidesMinimap = ({
353
375
  ? dropTarget.position
354
376
  : null
355
377
  }
378
+ onInsertAbove={
379
+ index === 0 ? () => insertCell(cell.id, true) : undefined
380
+ }
381
+ onInsertBelow={() => insertCell(cell.id, false)}
382
+ onDelete={() => deleteCell({ cellId: cell.id })}
356
383
  onClick={() => onSlideClick(index)}
357
384
  />
358
385
  ))}
@@ -399,6 +426,9 @@ interface SortableSlideThumbnailProps {
399
426
  isVisible?: boolean;
400
427
  isNoOutput?: boolean;
401
428
  slideType?: SlideType;
429
+ onInsertAbove?: () => void;
430
+ onInsertBelow?: () => void;
431
+ onDelete?: () => void;
402
432
  onClick?: () => void;
403
433
  }
404
434
 
@@ -411,6 +441,9 @@ const SortableSlideThumbnail = ({
411
441
  isVisible,
412
442
  isNoOutput,
413
443
  slideType,
444
+ onInsertAbove,
445
+ onInsertBelow,
446
+ onDelete,
414
447
  onClick,
415
448
  }: SortableSlideThumbnailProps) => {
416
449
  const { attributes, listeners, setNodeRef } = useSortable({
@@ -428,6 +461,9 @@ const SortableSlideThumbnail = ({
428
461
  isVisible={isVisible}
429
462
  isNoOutput={isNoOutput}
430
463
  slideType={slideType}
464
+ onInsertAbove={onInsertAbove}
465
+ onInsertBelow={onInsertBelow}
466
+ onDelete={onDelete}
431
467
  onClick={onClick}
432
468
  {...attributes}
433
469
  {...listeners}
@@ -446,6 +482,9 @@ const SlideThumbnailRow = ({
446
482
  isVisible,
447
483
  isNoOutput,
448
484
  slideType,
485
+ onInsertAbove,
486
+ onInsertBelow,
487
+ onDelete,
449
488
  onClick,
450
489
  ref,
451
490
  ...props
@@ -456,10 +495,23 @@ const SlideThumbnailRow = ({
456
495
  ...style,
457
496
  };
458
497
 
459
- return (
460
- <button
498
+ // Space is ignored as Reveal.js listens for Space on `document` to advance
499
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
500
+ if (event.target === event.currentTarget && event.key === "Enter") {
501
+ event.preventDefault();
502
+ event.stopPropagation();
503
+ event.currentTarget.click();
504
+ }
505
+ };
506
+
507
+ const row = (
508
+ <div
461
509
  ref={ref}
462
- type="button"
510
+ // A real <button> can't host the nested insert <button>s (invalid HTML),
511
+ // so we keep button semantics via role + keyboard handling below.
512
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
513
+ role="button"
514
+ tabIndex={0}
463
515
  data-cell-id={cell.id}
464
516
  className={cn(
465
517
  "relative shrink-0 appearance-none text-left p-0 bg-transparent outline-none",
@@ -467,6 +519,7 @@ const SlideThumbnailRow = ({
467
519
  )}
468
520
  style={rowStyle}
469
521
  onClick={onClick}
522
+ onKeyDown={handleKeyDown}
470
523
  {...props}
471
524
  >
472
525
  {dropIndicator && (
@@ -479,6 +532,9 @@ const SlideThumbnailRow = ({
479
532
  )}
480
533
  />
481
534
  )}
535
+ {onInsertAbove && (
536
+ <InsertCellLine position="above" onInsert={onInsertAbove} />
537
+ )}
482
538
  <SlideThumbnailCard
483
539
  cell={cell}
484
540
  dimensions={dimensions}
@@ -488,7 +544,68 @@ const SlideThumbnailRow = ({
488
544
  isNoOutput={isNoOutput}
489
545
  slideType={slideType}
490
546
  />
491
- </button>
547
+ {onInsertBelow && (
548
+ <InsertCellLine position="below" onInsert={onInsertBelow} />
549
+ )}
550
+ </div>
551
+ );
552
+
553
+ if (!onInsertBelow && !onDelete) {
554
+ return row;
555
+ }
556
+
557
+ return (
558
+ <ContextMenu>
559
+ <ContextMenuTrigger asChild={true}>{row}</ContextMenuTrigger>
560
+ <ContextMenuContent>
561
+ {onInsertBelow && (
562
+ <ContextMenuItem onSelect={onInsertBelow}>
563
+ <PlusIcon className="mr-2 h-3.5 w-3.5" />
564
+ Add cell
565
+ </ContextMenuItem>
566
+ )}
567
+ <ContextMenuSeparator />
568
+ {onDelete && (
569
+ <ContextMenuItem variant="danger" onSelect={onDelete}>
570
+ <Trash2Icon className="mr-2 h-3.5 w-3.5" />
571
+ Delete cell
572
+ </ContextMenuItem>
573
+ )}
574
+ </ContextMenuContent>
575
+ </ContextMenu>
576
+ );
577
+ };
578
+
579
+ const InsertCellLine = ({
580
+ position,
581
+ onInsert,
582
+ }: {
583
+ position: "above" | "below";
584
+ onInsert: () => void;
585
+ }) => {
586
+ return (
587
+ <Tooltip content="Add Python cell">
588
+ <button
589
+ type="button"
590
+ aria-label="Add Python cell"
591
+ data-testid="minimap-insert-cell"
592
+ className={cn(
593
+ "absolute left-0 right-0 z-30 flex h-3 items-center justify-center",
594
+ "opacity-0 transition-opacity hover:opacity-80 focus-visible:opacity-100 focus:outline-none",
595
+ position === "below"
596
+ ? "bottom-0 translate-y-1/2"
597
+ : "top-0 -translate-y-1/2",
598
+ )}
599
+ // Stop the pointer event from reaching the row's drag sensor / click.
600
+ onPointerDown={Events.stopPropagation()}
601
+ onClick={Events.stopPropagation(onInsert)}
602
+ >
603
+ <span className="absolute left-2 right-2 h-px rounded-full bg-blue-500" />
604
+ <span className="relative flex h-3 w-3 items-center justify-center rounded-full bg-blue-500 text-background shadow-xs">
605
+ <PlusIcon className="h-2 w-2" strokeWidth={3} />
606
+ </span>
607
+ </button>
608
+ </Tooltip>
492
609
  );
493
610
  };
494
611
 
@@ -529,10 +646,10 @@ const SlideThumbnailCard = ({
529
646
  <div
530
647
  ref={ref}
531
648
  className={cn(
532
- "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing overflow-hidden",
649
+ "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing overflow-hidden transition-colors",
533
650
  isActiveSlide || isActiveDragSource || isOverlay
534
651
  ? "border-blue-500"
535
- : "border-border",
652
+ : "border-border hover:border-blue-500/50",
536
653
  isActiveDragSource && !isOverlay && "opacity-35",
537
654
  isOverlay && "opacity-95 shadow-lg",
538
655
  className,
@@ -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,16 +145,12 @@ 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
 
144
156
  /**
@@ -204,35 +216,39 @@ const StorageEntryChildren: React.FC<{
204
216
  );
205
217
  }
206
218
 
207
- const filtered = filterEntries(
208
- children,
219
+ const filtered = filterEntries(children, {
209
220
  namespace,
210
221
  searchValue,
211
222
  entriesByPath,
212
- );
223
+ });
213
224
 
214
225
  return (
215
226
  <>
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}
224
- depth={depth}
225
- locale={locale}
226
- searchValue={searchValue}
227
- onOpenFile={onOpenFile}
228
- />
229
- ))}
227
+ {filtered.map((child) => {
228
+ const rowKey = storageEntryKey(child, children.indexOf(child));
229
+ return (
230
+ <StorageEntryRow
231
+ key={rowKey}
232
+ rowKey={rowKey}
233
+ entry={child}
234
+ namespace={namespace}
235
+ protocol={protocol}
236
+ rootPath={rootPath}
237
+ backendType={backendType}
238
+ depth={depth}
239
+ locale={locale}
240
+ searchValue={searchValue}
241
+ onOpenFile={onOpenFile}
242
+ />
243
+ );
244
+ })}
230
245
  </>
231
246
  );
232
247
  };
233
248
 
234
249
  const StorageEntryRow: React.FC<{
235
250
  entry: StorageEntry;
251
+ rowKey: string;
236
252
  namespace: string;
237
253
  protocol: string;
238
254
  rootPath: string;
@@ -243,6 +259,7 @@ const StorageEntryRow: React.FC<{
243
259
  onOpenFile: (info: OpenFileInfo) => void;
244
260
  }> = ({
245
261
  entry,
262
+ rowKey,
246
263
  namespace,
247
264
  protocol,
248
265
  rootPath,
@@ -271,7 +288,7 @@ const StorageEntryRow: React.FC<{
271
288
  !!entriesByPath
272
289
  .get(storagePathKey(namespace, entry.path))
273
290
  ?.some((child) =>
274
- entryMatchesSearch(child, namespace, searchValue, entriesByPath),
291
+ entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
275
292
  );
276
293
 
277
294
  // Folder is shown expanded by manual toggle OR by search auto-expand
@@ -312,7 +329,7 @@ const StorageEntryRow: React.FC<{
312
329
  isDir && "font-medium",
313
330
  )}
314
331
  style={indentStyle(depth)}
315
- value={`${namespace}:${entry.path}`}
332
+ value={`${namespace}:${rowKey}`}
316
333
  onSelect={() => {
317
334
  if (isDir) {
318
335
  setIsExpanded(!effectiveExpanded);
@@ -458,12 +475,11 @@ const StorageNamespaceSection: React.FC<{
458
475
 
459
476
  // While loading, fall back to initial entries from the namespace notification
460
477
  const entries = isPending ? namespace.storageEntries : fetchedEntries;
461
- const filtered = filterEntries(
462
- entries,
463
- namespaceName,
478
+ const filtered = filterEntries(entries, {
479
+ namespace: namespaceName,
464
480
  searchValue,
465
481
  entriesByPath,
466
- );
482
+ });
467
483
 
468
484
  return (
469
485
  <>
@@ -525,20 +541,24 @@ const StorageNamespaceSection: React.FC<{
525
541
  No matches
526
542
  </div>
527
543
  )}
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}
536
- depth={1}
537
- locale={locale}
538
- searchValue={searchValue}
539
- onOpenFile={onOpenFile}
540
- />
541
- ))}
544
+ {filtered.map((entry) => {
545
+ const rowKey = storageEntryKey(entry, entries.indexOf(entry));
546
+ return (
547
+ <StorageEntryRow
548
+ key={rowKey}
549
+ rowKey={rowKey}
550
+ entry={entry}
551
+ namespace={namespaceName}
552
+ protocol={namespace.protocol}
553
+ rootPath={namespace.rootPath}
554
+ backendType={namespace.backendType}
555
+ depth={1}
556
+ locale={locale}
557
+ searchValue={searchValue}
558
+ onOpenFile={onOpenFile}
559
+ />
560
+ );
561
+ })}
542
562
  </>
543
563
  )}
544
564
  </>
@@ -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
+ });
@@ -58,6 +58,10 @@ interface State {
58
58
 
59
59
  const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
60
60
 
61
+ // Keys of toasts requested with `once: true`, suppressed after first show
62
+ // for the lifetime of the page (i.e. one static-preview session).
63
+ const shownOnceKeys = new Set<string>();
64
+
61
65
  const addToRemoveQueue = (toastId: string) => {
62
66
  if (toastTimeouts.has(toastId)) {
63
67
  return;
@@ -159,9 +163,18 @@ function dispatch(action: Action) {
159
163
 
160
164
  type Toast = Omit<ToasterToast, "id">;
161
165
 
162
- function toast({ id, ...props }: Toast & { id?: string }) {
166
+ function toast({
167
+ id,
168
+ once,
169
+ ...props
170
+ }: Toast & { id?: string; once?: boolean }) {
163
171
  const toastId = id || genId();
164
172
 
173
+ // `once` dedupe requires a caller-provided stable `id`. A generated id is
174
+ // unique per call, so it could never match (and would grow the set without
175
+ // bound); gate on `id` so `once` without one is simply a no-op.
176
+ const dedupeOnce = once === true && id !== undefined;
177
+
165
178
  const update = (props: Toast) =>
166
179
  dispatch({
167
180
  type: "UPDATE_TOAST",
@@ -183,19 +196,26 @@ function toast({ id, ...props }: Toast & { id?: string }) {
183
196
  },
184
197
  });
185
198
 
186
- dispatch({
187
- type: "ADD_TOAST",
188
- toast: {
189
- ...props,
190
- id: toastId,
191
- open: true,
192
- onOpenChange: (open) => {
193
- if (!open) {
194
- dismiss();
195
- }
199
+ const suppressed = dedupeOnce && shownOnceKeys.has(toastId);
200
+ if (dedupeOnce) {
201
+ shownOnceKeys.add(toastId);
202
+ }
203
+
204
+ if (!suppressed) {
205
+ dispatch({
206
+ type: "ADD_TOAST",
207
+ toast: {
208
+ ...props,
209
+ id: toastId,
210
+ open: true,
211
+ onOpenChange: (open) => {
212
+ if (!open) {
213
+ dismiss();
214
+ }
215
+ },
196
216
  },
197
- },
198
- });
217
+ });
218
+ }
199
219
 
200
220
  return {
201
221
  id: toastId,