/* Copyright 2026 Marimo. All rights reserved. */ import type { EditorView } from "@codemirror/view"; import { useAtomValue } from "jotai"; import { AlertTriangleIcon, ChevronDownIcon, ChevronsDownIcon, ChevronsUpIcon, ChevronUpIcon, Code2Icon, EyeIcon, EyeOffIcon, MoreHorizontalIcon, PlayIcon, Trash2Icon, XCircleIcon, ZapIcon, ZapOffIcon, } from "lucide-react"; import React from "react"; import { FocusScope } from "react-aria"; import useEvent from "react-use-event-hook"; import { MinimalShortcut } from "@/components/shortcuts/renderShortcut"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { getCellEditorView, hasOnlyOneCellAtom, notebookAtom, useCellActions, } from "@/core/cells/cells"; import type { CellId } from "@/core/cells/ids"; import { usePendingDeleteService } from "@/core/cells/pending-delete-service"; import { formatEditorViews } from "@/core/codemirror/format"; import { userConfigAtom } from "@/core/config/config"; import type { HotkeyAction } from "@/core/hotkeys/hotkeys"; import { useRequestClient } from "@/core/network/requests"; import type { CellConfig } from "@/core/network/types"; import { store } from "@/core/state/jotai"; import { useEventListener } from "@/hooks/useEventListener"; import type { ActionButton } from "../actions/types"; import { useDeleteManyCellsCallback } from "../cell/useDeleteCell"; import { useRunCells } from "../cell/useRunCells"; import { useCellSelectionActions, useCellSelectionState } from "./selection"; interface MultiCellActionButton extends Omit { handle: (selectedCells: CellId[]) => void; hotkey?: HotkeyAction; } const CellStateDropdown: React.FC<{ actions: MultiCellActionButton[][]; cellIds: CellId[]; disabled?: boolean; }> = ({ actions, cellIds, disabled }) => { return ( {actions.flatMap((group, index) => { const groupItems = group.map((action) => { return ( action.handle(cellIds)} className="flex items-center gap-2" >
{action.icon && (
{action.icon}
)}
{action.label}
{action.hotkey && ( )}
); }); return ( {groupItems} {index < actions.length - 1 && } ); })}
); }; export function useMultiCellActionButtons(cellIds: CellId[]) { const { updateCellConfig, moveCell, clearCellOutput, sendToTop, sendToBottom, } = useCellActions(); const deleteCell = useDeleteManyCellsCallback(); const hasOnlyOneCell = useAtomValue(hasOnlyOneCellAtom); const selectionActions = useCellSelectionActions(); const runCells = useRunCells(); const pendingDeleteService = usePendingDeleteService(); const userConfig = useAtomValue(userConfigAtom); const { saveCellConfig } = useRequestClient(); const selectedCount = cellIds.length; const canDelete = !hasOnlyOneCell || selectedCount < cellIds.length; const deleteSelectedCells = useEvent((cellIds: CellId[]) => { // First click sets pending, second click deletes if (pendingDeleteService.idle && userConfig.keymap.destructive_delete) { pendingDeleteService.submit(cellIds); return; } deleteCell({ cellIds }); pendingDeleteService.clear(); selectionActions.clear(); }); const moveSelectedCells = useEvent( (cellIds: CellId[], direction: "up" | "down") => { /// If moving down, make sure the last cell is not at the bottom of the notebook if (direction === "down") { const lastCellId = cellIds[cellIds.length - 1]; const notebook = store.get(notebookAtom); const isLast = notebook.cellIds.findWithId(lastCellId).last() === lastCellId; if (isLast) { return; } } // If moving up, make sure the first cell is not at the top of the notebook if (direction === "up") { const firstCellId = cellIds[0]; const notebook = store.get(notebookAtom); const isFirst = notebook.cellIds.findWithId(firstCellId).first() === firstCellId; if (isFirst) { return; } } // Move cells in the appropriate order to maintain relative positions const sortedCells = direction === "up" ? cellIds : cellIds.toReversed(); sortedCells.forEach((cellId) => { moveCell({ cellId, before: direction === "up" }); }); }, ); const sendSelectedCellsToTop = useEvent((cellIds: CellId[]) => { // Send in reverse order to maintain relative positions cellIds.toReversed().forEach((cellId) => { sendToTop({ cellId }); }); }); const sendSelectedCellsToBottom = useEvent((cellIds: CellId[]) => { cellIds.forEach((cellId) => { sendToBottom({ cellId }); }); }); const formatSelectedCells = useEvent((cellIds: CellId[]) => { const editorViews: Record = {}; cellIds.forEach((cellId) => { const editorView = getCellEditorView(cellId); if (editorView) { editorViews[cellId] = editorView; } }); formatEditorViews(editorViews); }); const clearSelectedCellsOutput = useEvent((cellIds: CellId[]) => { cellIds.forEach((cellId) => { clearCellOutput({ cellId }); }); }); const toggleSelectedCellsProperty = useEvent( async (cellIds: CellId[], property: keyof CellConfig, value: boolean) => { const configs: Record> = {}; cellIds.forEach((cellId) => { configs[cellId] = { [property]: value }; }); await saveCellConfig({ configs }); cellIds.forEach((cellId) => { updateCellConfig({ cellId, config: configs[cellId] }); }); }, ); const actions: MultiCellActionButton[][] = [ [ { icon: , label: "Run cells", handle: (cellIds) => runCells(cellIds), hotkey: "cell.run", }, ], [ { icon: , label: "Move up", handle: (cellIds) => moveSelectedCells(cellIds, "up"), hotkey: "cell.moveUp", }, { icon: , label: "Move down", handle: (cellIds) => moveSelectedCells(cellIds, "down"), hotkey: "cell.moveDown", }, ], [ { icon: , label: "Delete cells", variant: "danger", hidden: !canDelete, handle: deleteSelectedCells, }, ], ]; const moreActions: MultiCellActionButton[][] = [ [ { icon: , label: "Format cells", handle: formatSelectedCells, hotkey: "cell.format", }, { icon: , label: "Clear outputs", handle: clearSelectedCellsOutput, }, ], [ { icon: , label: "Hide code", handle: (cellIds) => toggleSelectedCellsProperty(cellIds, "hide_code", true), hotkey: "cell.hideCode", }, { icon: , label: "Show code", handle: (cellIds) => toggleSelectedCellsProperty(cellIds, "hide_code", false), hotkey: "cell.hideCode", }, ], [ { icon: , label: "Move up", handle: (cellIds) => moveSelectedCells(cellIds, "up"), hotkey: "cell.moveUp", }, { icon: , label: "Move down", handle: (cellIds) => moveSelectedCells(cellIds, "down"), hotkey: "cell.moveDown", }, { icon: , label: "Send to top", handle: sendSelectedCellsToTop, hotkey: "cell.sendToTop", }, { icon: , label: "Send to bottom", handle: sendSelectedCellsToBottom, hotkey: "cell.sendToBottom", }, ], [ { icon: , label: "Disable cells", handle: (cellIds) => toggleSelectedCellsProperty(cellIds, "disabled", true), }, { icon: , label: "Enable cells", handle: (cellIds) => toggleSelectedCellsProperty(cellIds, "disabled", false), }, ], ]; // Filter out hidden actions and empty groups return { actions: actions .map((group) => group.filter((action) => !action.hidden)) .filter((group) => group.length > 0), moreActions: moreActions .map((group) => group.filter((action) => !action.hidden)) .filter((group) => group.length > 0), }; } export const MultiCellActionToolbar = () => { const selectionState = useCellSelectionState(); const selectedCells = [...selectionState.selected]; if (selectedCells.length < 2) { return null; } return ( <> ); }; const Separator = () =>
; const MultiCellPendingDeleteBar: React.FC<{ cellIds: CellId[] }> = ({ cellIds, }) => { const pendingDeleteService = usePendingDeleteService(); const deleteCell = useDeleteManyCellsCallback(); const selectionActions = useCellSelectionActions(); if (!pendingDeleteService.shouldConfirmDelete) { return null; } return (

Some cells in selection may contain expensive operations.

Are you sure you want to delete?

{ // Stop propagation to prevent Cell's resumeCompletionHandler e.stopPropagation(); }} >
); }; const MultiCellActionToolbarInternal = ({ cellIds }: { cellIds: CellId[] }) => { const selectionActions = useCellSelectionActions(); const pendingDeleteService = usePendingDeleteService(); const { actions, moreActions } = useMultiCellActionButtons(cellIds); const selectedCount = cellIds.length; useEventListener(window, "mousedown", (evt) => { // Clear selected, unless clicked inside an element that contains data-keep-cell-selection if ( (evt.target instanceof HTMLElement || evt.target instanceof SVGElement) && evt.target.closest("[data-keep-cell-selection]") !== null ) { return; } // HACK: evt.target is the root element // when the DropdownMenu is closed. // This prevents the selection from being cleared when the opens/closes // the DropdownMenu. if (evt.target instanceof HTMLHtmlElement) { return; } pendingDeleteService.clear(); selectionActions.clear(); }); const isPendingDelete = !pendingDeleteService.idle; return (
{selectedCount} cells selected {actions.map((group, groupIndex) => (
{group.map((action) => ( ))} {groupIndex < actions.length - 1 && }
))}
); };