/* Copyright 2026 Marimo. All rights reserved. */ import { useAtomValue } from "jotai"; import * as React from "react"; import { type CellGraph, cellGraphsAtom, isVariableAffectedBySelectedCell, } from "@/components/editor/chrome/wrapper/minimap-state"; import { useCellActions, useCellData, useCellIds, useCellRuntime, } from "@/core/cells/cells"; import { cellFocusAtom, useCellFocusActions } from "@/core/cells/focus"; import type { CellId } from "@/core/cells/ids"; import { useVariables } from "@/core/variables/state"; import { cn } from "@/utils/cn"; import { extractCellPreview } from "./utils/cell-preview"; interface MinimapCellProps { cellId: CellId; cellPositions: Readonly>; } const MinimapCell: React.FC = (props) => { const focusState = useAtomValue(cellFocusAtom); const graphs = useAtomValue(cellGraphsAtom); const actions = useCellActions(); const focusActions = useCellFocusActions(); const cell = { id: props.cellId, graph: graphs[props.cellId], code: useCellData(props.cellId).code, hasError: useCellRuntime(props.cellId).errored, }; let selectedCell: undefined | { id: CellId; graph: CellGraph }; if (focusState.focusedCellId && graphs[focusState.focusedCellId]) { selectedCell = { id: focusState.focusedCellId, graph: graphs[focusState.focusedCellId], }; } const isSelected = selectedCell?.id === cell.id; const preview = extractCellPreview(cell.code); const circleRadius = isNonReferenceableCell(cell.graph) ? 1.5 : 3; const handleClick = () => { if (isSelected) { // If clicking the already focused cell, blur it focusActions.blurCell(); } else { // Otherwise focus the cell actions.focusCell({ cellId: cell.id, where: "exact" }); focusActions.focusCell({ cellId: cell.id }); } }; return ( ); }; const VariablesList: React.FC<{ cell: { id: CellId; graph: CellGraph }; selectedCell?: { id: CellId; graph: CellGraph }; }> = ({ cell, selectedCell }) => { const variables = useVariables(); const isSelected = cell.id === selectedCell?.id; return ( <> {cell.graph.variables.map((varName, idx) => { const variable = variables[varName]; return ( {idx > 0 && ", "} {varName} ); })} ); }; // Connection paths (for selected) const SelectedCell = (options: { cell: { id: CellId; graph: CellGraph }; cellPositions: Record; circleRadius: number; }) => { const { cell, cellPositions, circleRadius } = options; const dy = 21; const paths: React.ReactElement[] = []; const currentY = cellPositions[cell.id] ?? 0; // First, identify all cycles (nodes that are both parent and child) const cycles = new Set(); for (const parentCellId of cell.graph.parents) { if (cell.graph.children.has(parentCellId)) { cycles.add(parentCellId); } } // Add cycle paths first for (const cycleCellId of cycles) { const targetY = cellPositions[cycleCellId]; if (targetY !== undefined) { const yDiff = (targetY - currentY) * dy; // Draw a rectangular path around both nodes to show the cycle paths.push( , ); } } // Add regular upstream connections (excluding cycles) for (const parentCellId of cell.graph.parents) { if (cycles.has(parentCellId)) { continue; // Skip - already handled as cycle } const targetY = cellPositions[parentCellId]; if (targetY !== undefined) { const yDiff = (targetY - currentY) * dy; paths.push( , ); } } // Add regular downstream connections (excluding cycles) for (const childCellId of cell.graph.children) { if (cycles.has(childCellId)) { continue; // Skip - already handled as cycle } const targetY = cellPositions[childCellId]; if (targetY !== undefined) { const yDiff = (targetY - currentY) * dy; paths.push( , ); } } return ( {paths} ); }; // Connection indicators for non-selected cells function UnselectedCell(options: { cell: { id: CellId; graph: CellGraph }; selectedCell?: { id: CellId; graph: CellGraph }; circleRadius: number; }) { const { cell, selectedCell, circleRadius } = options; const hasAncestors = cell.graph.ancestors.size > 0; const hasDescendants = cell.graph.descendants.size > 0; if (!selectedCell) { // There is no selection, so show all upstream/downstream indicators return drawConnectionGlyph({ circleRadius, leftWisker: hasAncestors, rightWisker: hasDescendants, }); } const isAncestorOfSelected = selectedCell.graph.ancestors.has(cell.id); const isDescendantOfSelected = selectedCell.graph.descendants.has(cell.id); if (isAncestorOfSelected || isDescendantOfSelected) { return drawConnectionGlyph({ circleRadius, leftWisker: hasAncestors, rightWisker: hasDescendants, // Node is a part of the current selection, need to jitter // If it's both ancestor and descendant (cycle), keep it centered shift: isAncestorOfSelected && isDescendantOfSelected ? undefined : isAncestorOfSelected ? "left" : "right", }); } // Node is outside of the current selection (keep center & hide upstream/downstream indicators) return drawConnectionGlyph({ circleRadius, leftWisker: false, rightWisker: false, }); } function drawConnectionGlyph(options: { circleRadius: number; leftWisker: boolean; rightWisker: boolean; shift?: "left" | "right"; }) { const { circleRadius, leftWisker, rightWisker, shift } = options; const dx = shift === undefined ? 0 : shift === "left" ? -14 : 14; return ( {/* Whisker pointing left (has upstream connections) */} {leftWisker && ( )} {/* Whisker pointing right (has downstream connections) */} {rightWisker && ( )} ); } /** * Color for the node/connections in the SVG diagram */ function getTextColor({ cell, selectedCell, }: { cell: { id: CellId; hasError: boolean; graph: CellGraph }; selectedCell?: { id: CellId; graph: CellGraph }; }) { if (cell.hasError) { return "text-destructive"; } // Nothing selected. Nodes that declare or uses variables if (!selectedCell && !isNonReferenceableCell(cell.graph)) { return "text-foreground"; } // Inside the selected graph if ( selectedCell?.id === cell.id || selectedCell?.graph.parents.has(cell.id) || selectedCell?.graph.children.has(cell.id) ) { return "text-primary"; } return "text-(--gray-8)"; } /** * Whether a cell is unconnected AND does not declare any variables */ function isNonReferenceableCell(graph: CellGraph): boolean { return ( graph.variables.length === 0 && graph.ancestors.size === 0 && graph.descendants.size === 0 ); } /** * Minimap content component for display in the dependencies panel. * Shows a scrollable list of cells with dependency visualization. */ export const MinimapContent: React.FC = () => { const cellIds = useCellIds(); const cellPositions: Record = Object.fromEntries( cellIds.inOrderIds.map((id, idx) => [id, idx]), ); const columnBoundaries: number[] = []; let cellCount = 0; for (const [idx, column] of cellIds.getColumns().entries()) { if (idx > 0) { columnBoundaries.push(cellCount); } cellCount += column.inOrderIds.length; } return (
{cellIds.inOrderIds.map((cellId, idx) => { const isColumnBoundary = columnBoundaries.includes(idx); return ( {/* Subtle visual divider between nodes */} {isColumnBoundary && ( {/* Invisible element to prevent SVG overflow from affecting scroll */} ); };