@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.
- package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DdeG-Hi-.js} +23 -23
- package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-CiES2a2h.js} +2 -2
- package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
- package/dist/assets/worker-CC0Oul9k.js +73 -0
- package/dist/{chat-ui-ar37brtL.js → chat-ui-BTobdMRF.js} +61 -61
- package/dist/{code-visibility-B88v1No3.js → code-visibility-Cu6I0RUK.js} +1212 -1046
- package/dist/{copy-BuQpJEzp.js → copy-5jQ_kGE1.js} +32 -32
- package/dist/{esm-BfhQmZjp.js → esm-CCuYCd3R.js} +1 -1
- package/dist/{extends-BgdxCfYu.js → extends-CkydH1Q5.js} +1 -1
- package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-CRvL2R9l.js} +7 -7
- package/dist/{html-to-image-Cp8O1OWB.js → html-to-image-CjsdUYrb.js} +2258 -2238
- package/dist/{input-_2sjvfne.js → input-DVkbXbIX.js} +183 -181
- package/dist/main.js +1565 -1363
- package/dist/{process-output-CaUUWhh8.js → process-output-CI8a-CUx.js} +2 -2
- package/dist/{reveal-component-CfFoUPFg.js → reveal-component-EOadhR-6.js} +5 -5
- package/dist/{spec-B96zNUEA.js → spec-DMRQmLOc.js} +2 -2
- package/dist/{strings-Bu3vlb6W.js → strings-GCJA9n6d.js} +25 -24
- package/dist/style.css +1 -1
- package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-BRcO_TGJ.js} +1 -1
- package/package.json +3 -3
- package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
- package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
- package/src/components/data-table/hover-tooltip/content.ts +44 -0
- package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
- package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
- package/src/components/data-table/renderers.tsx +27 -43
- package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
- package/src/components/datasources/datasources.tsx +92 -3
- package/src/components/editor/cell/cell-context-menu.tsx +15 -2
- package/src/components/editor/documentation.css +16 -0
- package/src/components/editor/file-tree/file-explorer.tsx +8 -18
- package/src/components/editor/file-tree/tree-actions.tsx +46 -1
- package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
- package/src/components/slides/minimap.tsx +127 -10
- package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
- package/src/components/storage/storage-inspector.tsx +68 -48
- package/src/components/ui/__tests__/use-toast.test.ts +75 -0
- package/src/components/ui/use-toast.ts +33 -13
- package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
- package/src/core/cells/__tests__/cell.test.ts +29 -2
- package/src/core/cells/cell.ts +5 -1
- package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +37 -0
- package/src/core/codemirror/go-to-definition/commands.ts +17 -9
- package/src/core/codemirror/go-to-definition/utils.ts +1 -0
- package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
- package/src/core/datasets/data-source-connections.ts +2 -0
- package/src/core/network/__tests__/requests-static.test.ts +30 -0
- package/src/core/network/requests-static.ts +14 -10
- package/src/core/wasm/worker/bootstrap.ts +12 -4
- package/src/plugins/layout/DownloadPlugin.tsx +1 -1
- package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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}:${
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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({
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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,
|