@marimo-team/islands 0.23.3-dev71 → 0.23.3-dev9

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.
@@ -6,21 +6,18 @@ import {
6
6
  ArrowLeftIcon,
7
7
  BetweenHorizontalStartIcon,
8
8
  BracesIcon,
9
- CopyIcon,
10
9
  CopyMinusIcon,
11
10
  DownloadIcon,
12
- Edit3Icon,
13
11
  ExternalLinkIcon,
14
12
  EyeOffIcon,
15
13
  FilePlus2Icon,
16
14
  FolderPlusIcon,
17
15
  ListTreeIcon,
18
16
  PlaySquareIcon,
19
- Trash2Icon,
20
17
  UploadIcon,
21
18
  ViewIcon,
22
19
  } from "lucide-react";
23
- import React, { Suspense, use, useEffect, useRef, useState } from "react";
20
+ import React, { Suspense, use, useRef, useState } from "react";
24
21
  import {
25
22
  type NodeApi,
26
23
  type NodeRendererProps,
@@ -34,9 +31,15 @@ import {
34
31
  type FileIconType,
35
32
  guessFileIconType,
36
33
  } from "@/components/editor/file-tree/file-icons";
34
+ import {
35
+ DeleteMenuItem,
36
+ DuplicateMenuItem,
37
+ FileActionsDropdown,
38
+ RenameMenuItem,
39
+ } from "@/components/editor/file-tree/file-operations";
40
+ import { FileNameInput } from "@/components/editor/file-tree/file-name-input";
37
41
  import {
38
42
  MENU_ITEM_ICON_CLASS,
39
- MoreActionsButton,
40
43
  RefreshIconButton,
41
44
  TreeChevron,
42
45
  } from "@/components/editor/file-tree/tree-actions";
@@ -46,11 +49,8 @@ import { useImperativeModal } from "@/components/modal/ImperativeModal";
46
49
  import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
47
50
  import { Button, buttonVariants } from "@/components/ui/button";
48
51
  import {
49
- DropdownMenu,
50
- DropdownMenuContent,
51
52
  DropdownMenuItem,
52
53
  DropdownMenuSeparator,
53
- DropdownMenuTrigger,
54
54
  } from "@/components/ui/dropdown-menu";
55
55
  import { Tooltip } from "@/components/ui/tooltip";
56
56
  import { toast } from "@/components/ui/use-toast";
@@ -69,7 +69,7 @@ import { downloadBlob } from "@/utils/download";
69
69
  import { type Base64String, base64ToDataURL } from "@/utils/json/base64";
70
70
  import { openNotebook } from "@/utils/links";
71
71
  import type { FilePath } from "@/utils/paths";
72
- import { fileSplit } from "@/utils/pathUtils";
72
+ import { makeDuplicateName } from "@/utils/pathUtils";
73
73
  import { jotaiJsonStorage } from "@/utils/storage/jotai";
74
74
  import { useTreeDndManager } from "./dnd-wrapper";
75
75
  import { FileViewer } from "./file-viewer";
@@ -131,7 +131,7 @@ export const FileExplorer: React.FC<{
131
131
  openPrompt({
132
132
  title: "File name",
133
133
  onConfirm: async (name) => {
134
- tree.createFile(name, null);
134
+ tree.createFile({ name, parentId: null });
135
135
  },
136
136
  });
137
137
  });
@@ -140,7 +140,7 @@ export const FileExplorer: React.FC<{
140
140
  openPrompt({
141
141
  title: "Notebook name",
142
142
  onConfirm: async (name) => {
143
- tree.createFile(name, null, "notebook");
143
+ tree.createFile({ name, parentId: null, type: "notebook" });
144
144
  },
145
145
  });
146
146
  });
@@ -392,33 +392,6 @@ const Show = ({
392
392
  );
393
393
  };
394
394
 
395
- const Edit = ({ node }: { node: NodeApi<FileInfo> }) => {
396
- const ref = useRef<HTMLInputElement>(null);
397
- useEffect(() => {
398
- ref.current?.focus();
399
- // Select everything, but the extension
400
- ref.current?.setSelectionRange(0, node.data.name.lastIndexOf("."));
401
- }, [node.data.name]);
402
-
403
- return (
404
- <input
405
- ref={ref}
406
- className="flex-1 bg-transparent border border-border text-muted-foreground"
407
- defaultValue={node.data.name}
408
- onClick={(e) => e.stopPropagation()}
409
- onBlur={() => node.reset()}
410
- onKeyDown={(e) => {
411
- if (e.key === "Escape") {
412
- node.reset();
413
- }
414
- if (e.key === "Enter") {
415
- node.submit(e.currentTarget.value);
416
- }
417
- }}
418
- />
419
- );
420
- };
421
-
422
395
  const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
423
396
  const { openFile, sendFileDetails } = useRequestClient();
424
397
  const disableFileDownloads = useAtomValue(disableFileDownloadsAtom);
@@ -486,7 +459,7 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
486
459
  openPrompt({
487
460
  title: "File name",
488
461
  onConfirm: async (name) => {
489
- tree?.createFile(name, node.id);
462
+ tree?.createFile({ name, parentId: node.id });
490
463
  },
491
464
  });
492
465
  });
@@ -496,7 +469,7 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
496
469
  openPrompt({
497
470
  title: "Notebook name",
498
471
  onConfirm: async (name) => {
499
- tree?.createFile(name, node.id, "notebook");
472
+ tree?.createFile({ name, parentId: node.id, type: "notebook" });
500
473
  },
501
474
  });
502
475
  });
@@ -505,158 +478,9 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
505
478
  if (!tree) {
506
479
  return;
507
480
  }
508
-
509
- const [name, extension] = fileSplit(node.data.name);
510
- const duplicateName = `${name}_copy${extension}`;
511
-
512
- await tree.copy(node.id, duplicateName);
481
+ await tree.copy(node.id, makeDuplicateName(node.data.name));
513
482
  });
514
483
 
515
- const renderActions = () => {
516
- const ic = MENU_ITEM_ICON_CLASS;
517
- return (
518
- <DropdownMenuContent
519
- align="end"
520
- className="print:hidden w-[220px]"
521
- onClick={(e) => e.stopPropagation()}
522
- onCloseAutoFocus={(e) => e.preventDefault()}
523
- >
524
- {!node.data.isDirectory && (
525
- <DropdownMenuItem onSelect={() => node.select()}>
526
- <ViewIcon className={ic} />
527
- Open file
528
- </DropdownMenuItem>
529
- )}
530
- {!node.data.isDirectory && !isWasm() && (
531
- <DropdownMenuItem
532
- onSelect={() => {
533
- openFile({ path: node.data.path });
534
- }}
535
- >
536
- <ExternalLinkIcon className={ic} />
537
- Open file in external editor
538
- </DropdownMenuItem>
539
- )}
540
- {node.data.isDirectory && (
541
- <>
542
- <DropdownMenuItem onSelect={() => handleCreateNotebook()}>
543
- <MarimoPlusIcon className={ic} />
544
- Create notebook
545
- </DropdownMenuItem>
546
- <DropdownMenuItem onSelect={() => handleCreateFile()}>
547
- <FilePlus2Icon className={ic} />
548
- Create file
549
- </DropdownMenuItem>
550
- <DropdownMenuItem onSelect={() => handleCreateFolder()}>
551
- <FolderPlusIcon className={ic} />
552
- Create folder
553
- </DropdownMenuItem>
554
- <DropdownMenuSeparator />
555
- </>
556
- )}
557
- <DropdownMenuItem onSelect={() => node.edit()}>
558
- <Edit3Icon className={ic} />
559
- Rename
560
- </DropdownMenuItem>
561
- <DropdownMenuItem onSelect={handleDuplicate}>
562
- <CopyIcon className={ic} />
563
- Duplicate
564
- </DropdownMenuItem>
565
- <DropdownMenuItem
566
- onSelect={async () => {
567
- await copyToClipboard(node.data.path);
568
- toast({ title: "Copied to clipboard" });
569
- }}
570
- >
571
- <ListTreeIcon className={ic} />
572
- Copy path
573
- </DropdownMenuItem>
574
- {tree && (
575
- <DropdownMenuItem
576
- onSelect={async () => {
577
- await copyToClipboard(
578
- tree.relativeFromRoot(node.data.path as FilePath),
579
- );
580
- toast({ title: "Copied to clipboard" });
581
- }}
582
- >
583
- <ListTreeIcon className={ic} />
584
- Copy relative path
585
- </DropdownMenuItem>
586
- )}
587
- <DropdownMenuSeparator />
588
-
589
- <DropdownMenuItem
590
- onSelect={() => {
591
- const { path } = node.data;
592
- const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path);
593
- handleInsertCode(pythonCode);
594
- }}
595
- >
596
- <BetweenHorizontalStartIcon className={ic} />
597
- Insert snippet for reading file
598
- </DropdownMenuItem>
599
- <DropdownMenuItem
600
- onSelect={async () => {
601
- toast({
602
- title: "Copied to clipboard",
603
- description:
604
- "Code to open the file has been copied to your clipboard. You can also drag and drop this file into the editor",
605
- });
606
- const { path } = node.data;
607
- const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path);
608
- await copyToClipboard(pythonCode);
609
- }}
610
- >
611
- <BracesIcon className={ic} />
612
- Copy snippet for reading file
613
- </DropdownMenuItem>
614
- {/* Not shown in WASM */}
615
- {node.data.isMarimoFile && !isWasm() && (
616
- <>
617
- <DropdownMenuSeparator />
618
- <DropdownMenuItem onSelect={handleOpenMarimoFile}>
619
- <PlaySquareIcon className={ic} />
620
- Open notebook
621
- </DropdownMenuItem>
622
- </>
623
- )}
624
- <DropdownMenuSeparator />
625
- {!node.data.isDirectory && !disableFileDownloads && (
626
- <>
627
- <DropdownMenuItem
628
- onSelect={async () => {
629
- const details = await sendFileDetails({ path: node.data.path });
630
- if (details.isBase64 && details.contents) {
631
- const blob = deserializeBlob(
632
- base64ToDataURL(
633
- details.contents as Base64String,
634
- details.mimeType || "application/octet-stream",
635
- ),
636
- );
637
- downloadBlob(blob, node.data.name);
638
- } else {
639
- downloadBlob(
640
- new Blob([details.contents || ""]),
641
- node.data.name,
642
- );
643
- }
644
- }}
645
- >
646
- <DownloadIcon className={ic} />
647
- Download
648
- </DropdownMenuItem>
649
- <DropdownMenuSeparator />
650
- </>
651
- )}
652
- <DropdownMenuItem onSelect={handleDeleteFile} variant="danger">
653
- <Trash2Icon className={ic} />
654
- Delete
655
- </DropdownMenuItem>
656
- </DropdownMenuContent>
657
- );
658
- };
659
-
660
484
  return (
661
485
  <div
662
486
  style={style}
@@ -690,23 +514,138 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
690
514
  />
691
515
  )}
692
516
  {node.isEditing ? (
693
- <Edit node={node} />
517
+ <FileNameInput node={node} />
694
518
  ) : (
695
519
  <Show node={node} onOpenMarimoFile={handleOpenMarimoFile} />
696
520
  )}
697
- <DropdownMenu modal={false}>
698
- <DropdownMenuTrigger
699
- asChild={true}
700
- tabIndex={-1}
701
- onClick={(e) => e.stopPropagation()}
521
+ <FileActionsDropdown
522
+ testId="file-explorer-more-button"
523
+ iconClassName="w-5 h-5"
524
+ >
525
+ {!node.data.isDirectory && (
526
+ <DropdownMenuItem onSelect={() => node.select()}>
527
+ <ViewIcon className={MENU_ITEM_ICON_CLASS} />
528
+ Open file
529
+ </DropdownMenuItem>
530
+ )}
531
+ {!node.data.isDirectory && !isWasm() && (
532
+ <DropdownMenuItem
533
+ onSelect={() => {
534
+ openFile({ path: node.data.path });
535
+ }}
536
+ >
537
+ <ExternalLinkIcon className={MENU_ITEM_ICON_CLASS} />
538
+ Open file in external editor
539
+ </DropdownMenuItem>
540
+ )}
541
+ {node.data.isDirectory && (
542
+ <>
543
+ <DropdownMenuItem onSelect={() => handleCreateNotebook()}>
544
+ <MarimoPlusIcon className={MENU_ITEM_ICON_CLASS} />
545
+ Create notebook
546
+ </DropdownMenuItem>
547
+ <DropdownMenuItem onSelect={() => handleCreateFile()}>
548
+ <FilePlus2Icon className={MENU_ITEM_ICON_CLASS} />
549
+ Create file
550
+ </DropdownMenuItem>
551
+ <DropdownMenuItem onSelect={() => handleCreateFolder()}>
552
+ <FolderPlusIcon className={MENU_ITEM_ICON_CLASS} />
553
+ Create folder
554
+ </DropdownMenuItem>
555
+ <DropdownMenuSeparator />
556
+ </>
557
+ )}
558
+ <RenameMenuItem onSelect={() => node.edit()} />
559
+ <DuplicateMenuItem onSelect={handleDuplicate} />
560
+ <DropdownMenuItem
561
+ onSelect={async () => {
562
+ await copyToClipboard(node.data.path);
563
+ toast({ title: "Copied to clipboard" });
564
+ }}
702
565
  >
703
- <MoreActionsButton
704
- data-testid="file-explorer-more-button"
705
- iconClassName="w-5 h-5"
706
- />
707
- </DropdownMenuTrigger>
708
- {renderActions()}
709
- </DropdownMenu>
566
+ <ListTreeIcon className={MENU_ITEM_ICON_CLASS} />
567
+ Copy path
568
+ </DropdownMenuItem>
569
+ {tree && (
570
+ <DropdownMenuItem
571
+ onSelect={async () => {
572
+ await copyToClipboard(
573
+ tree.relativeFromRoot(node.data.path as FilePath),
574
+ );
575
+ toast({ title: "Copied to clipboard" });
576
+ }}
577
+ >
578
+ <ListTreeIcon className={MENU_ITEM_ICON_CLASS} />
579
+ Copy relative path
580
+ </DropdownMenuItem>
581
+ )}
582
+ <DropdownMenuSeparator />
583
+ <DropdownMenuItem
584
+ onSelect={() => {
585
+ const { path } = node.data;
586
+ const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path);
587
+ handleInsertCode(pythonCode);
588
+ }}
589
+ >
590
+ <BetweenHorizontalStartIcon className={MENU_ITEM_ICON_CLASS} />
591
+ Insert snippet for reading file
592
+ </DropdownMenuItem>
593
+ <DropdownMenuItem
594
+ onSelect={async () => {
595
+ toast({
596
+ title: "Copied to clipboard",
597
+ description:
598
+ "Code to open the file has been copied to your clipboard. You can also drag and drop this file into the editor",
599
+ });
600
+ const { path } = node.data;
601
+ const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path);
602
+ await copyToClipboard(pythonCode);
603
+ }}
604
+ >
605
+ <BracesIcon className={MENU_ITEM_ICON_CLASS} />
606
+ Copy snippet for reading file
607
+ </DropdownMenuItem>
608
+ {node.data.isMarimoFile && !isWasm() && (
609
+ <>
610
+ <DropdownMenuSeparator />
611
+ <DropdownMenuItem onSelect={handleOpenMarimoFile}>
612
+ <PlaySquareIcon className={MENU_ITEM_ICON_CLASS} />
613
+ Open notebook
614
+ </DropdownMenuItem>
615
+ </>
616
+ )}
617
+ <DropdownMenuSeparator />
618
+ {!node.data.isDirectory && !disableFileDownloads && (
619
+ <>
620
+ <DropdownMenuItem
621
+ onSelect={async () => {
622
+ const details = await sendFileDetails({
623
+ path: node.data.path,
624
+ });
625
+ if (details.isBase64 && details.contents) {
626
+ const blob = deserializeBlob(
627
+ base64ToDataURL(
628
+ details.contents as Base64String,
629
+ details.mimeType || "application/octet-stream",
630
+ ),
631
+ );
632
+ downloadBlob(blob, node.data.name);
633
+ } else {
634
+ downloadBlob(
635
+ new Blob([details.contents || ""]),
636
+ node.data.name,
637
+ );
638
+ }
639
+ }}
640
+ >
641
+ <DownloadIcon className={MENU_ITEM_ICON_CLASS} />
642
+ Download
643
+ </DropdownMenuItem>
644
+ <DropdownMenuSeparator />
645
+ </>
646
+ )}
647
+ <DeleteMenuItem onSelect={handleDeleteFile} />
648
+ </FileActionsDropdown>
710
649
  </span>
711
650
  </div>
712
651
  );
@@ -0,0 +1,41 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { NodeApi } from "react-arborist";
4
+ import { useEffect, useRef } from "react";
5
+ import type { FileInfo } from "@/core/network/types";
6
+
7
+ /**
8
+ * Inline rename input used by `react-arborist` nodes when `node.isEditing`
9
+ * is true. Auto-focuses and selects everything except the extension so a
10
+ * user can type straight into the name.
11
+ */
12
+ export const FileNameInput = ({ node }: { node: NodeApi<FileInfo> }) => {
13
+ const ref = useRef<HTMLInputElement>(null);
14
+ useEffect(() => {
15
+ ref.current?.focus();
16
+ // Select everything but the extension. For extensionless names
17
+ // (`README`) and dotfiles (`.env`), select the full name.
18
+ const name = node.data.name;
19
+ const dotIndex = name.lastIndexOf(".");
20
+ const end = dotIndex > 0 ? dotIndex : name.length;
21
+ ref.current?.setSelectionRange(0, end);
22
+ }, [node.data.name]);
23
+
24
+ return (
25
+ <input
26
+ ref={ref}
27
+ className="flex-1 bg-transparent border border-border text-muted-foreground"
28
+ defaultValue={node.data.name}
29
+ onClick={(e) => e.stopPropagation()}
30
+ onBlur={() => node.reset()}
31
+ onKeyDown={(e) => {
32
+ if (e.key === "Escape") {
33
+ node.reset();
34
+ }
35
+ if (e.key === "Enter") {
36
+ node.submit(e.currentTarget.value);
37
+ }
38
+ }}
39
+ />
40
+ );
41
+ };