@marimo-team/islands 0.24.1-dev113 → 0.24.1-dev115

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 (65) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CF7VGRML.js → ConnectedDataExplorerComponent-3vyrwCHu.js} +12 -12
  2. package/dist/{ErrorBoundary-Ca9Ulp6E.js → ErrorBoundary-QF0xsxMw.js} +3 -3
  3. package/dist/{any-language-editor-D6lJfTIq.js → any-language-editor-B2e3EkTe.js} +4 -4
  4. package/dist/{button-BeWeF5Z2.js → button-CKBkqR9J.js} +1 -1
  5. package/dist/{capabilities-C7Q0J4_N.js → capabilities-CMBBJTcc.js} +1 -1
  6. package/dist/{chat-ui-DwtGBnE7.js → chat-ui-BgGHMO2G.js} +16 -16
  7. package/dist/{check-DGKNz9MB.js → check-C_-c2CXl.js} +1 -1
  8. package/dist/{common-CHiGez3b.js → common-Cdx64lyR.js} +23 -23
  9. package/dist/{copy-T56I_gVc.js → copy-D4GHy7GA.js} +2 -2
  10. package/dist/{dist-CSjGX93W.js → dist-mymH-A_x.js} +2 -2
  11. package/dist/{dropdown-menu-D-pePvPo.js → dropdown-menu-Div6gwlw.js} +6 -6
  12. package/dist/{error-banner-C0gx40vn.js → error-banner-DmgOcFS3.js} +2 -2
  13. package/dist/{esm-aqYR5Dl6.js → esm-CmFEQJjP.js} +1 -1
  14. package/dist/{extends-G2pVMdwU.js → extends-CGTKDWHv.js} +4 -4
  15. package/dist/{formats-CX_fkiT0.js → formats-DuRRWVwb.js} +3 -3
  16. package/dist/{glide-data-editor-BXnH2j9C.js → glide-data-editor-ekJ5GwaZ.js} +8 -8
  17. package/dist/{glide-utils-AvKWWLSq.js → glide-utils-Dwc8Eh8G.js} +3 -3
  18. package/dist/{html-to-image-Cf3vfRXj.js → html-to-image-u8Ik6EnK.js} +20 -20
  19. package/dist/{label-BUGMEGks.js → label-DdGS_CZZ.js} +2 -2
  20. package/dist/{loader-BIZus5lj.js → loader-VZ__bv4g.js} +1 -1
  21. package/dist/main.js +38 -38
  22. package/dist/{mermaid-CzrpWuKV.js → mermaid-Bt45Zzl4.js} +4 -4
  23. package/dist/{process-output-BZs2qaau.js → process-output-DQiqt-_U.js} +3 -3
  24. package/dist/{reveal-component-BSz_VbT2.js → reveal-component-tQ-rgxcQ.js} +9 -9
  25. package/dist/{schemas-CLB4UWgG.js → schemas-BjeOpZLk.js} +1 -1
  26. package/dist/{select-foAi7oM_.js → select-DotAQ4jp.js} +12 -12
  27. package/dist/{spec-JFqQpvZq.js → spec-BesaYkGC.js} +4 -4
  28. package/dist/style.css +1 -1
  29. package/dist/{swiper-component-Bqzl6az1.js → swiper-component-C90YjhaV.js} +2 -2
  30. package/dist/{toDate-DxgaJjev.js → toDate-CrAIOASL.js} +3 -3
  31. package/dist/{tooltip-B1X8ECYn.js → tooltip-COXZxyl7.js} +3 -3
  32. package/dist/{useAsyncData-BkvtR_Y2.js → useAsyncData-x0_tWDWI.js} +1 -1
  33. package/dist/{useDateFormatter-DGIhtjdF.js → useDateFormatter-B3V__WqX.js} +2 -2
  34. package/dist/{useDeepCompareMemoize-DcZEHOmt.js → useDeepCompareMemoize-KaRHJEEU.js} +1 -1
  35. package/dist/{useIframeCapabilities-Bj4ORYGz.js → useIframeCapabilities-De3C-9b4.js} +1 -1
  36. package/dist/{useLifecycle-BHpzFL9q.js → useLifecycle-x0OriHo-.js} +10 -10
  37. package/dist/{useTheme-BlOgV7zf.js → useTheme-Bm9_FyqO.js} +2 -2
  38. package/dist/{vega-component-B4rwjseP.js → vega-component-nD2oP6WN.js} +9 -9
  39. package/package.json +1 -1
  40. package/src/components/dependency-graph/__tests__/elements.test.ts +151 -0
  41. package/src/components/dependency-graph/custom-edge.tsx +68 -0
  42. package/src/components/dependency-graph/custom-node.tsx +54 -26
  43. package/src/components/dependency-graph/dependency-graph-tree.tsx +110 -21
  44. package/src/components/dependency-graph/elements.ts +225 -31
  45. package/src/components/dependency-graph/panels.tsx +23 -2
  46. package/src/components/dependency-graph/utils/__tests__/layout.test.ts +78 -0
  47. package/src/components/dependency-graph/utils/layout.ts +108 -18
  48. package/src/components/ui/__tests__/badge.test.ts +63 -0
  49. package/src/components/ui/__tests__/button.test.ts +19 -0
  50. package/src/components/ui/__tests__/menu-items.test.ts +31 -0
  51. package/src/components/ui/badge.tsx +9 -7
  52. package/src/components/ui/button.tsx +1 -0
  53. package/src/components/ui/combobox.tsx +1 -1
  54. package/src/components/ui/date-input.tsx +1 -1
  55. package/src/components/ui/dialog.tsx +3 -1
  56. package/src/components/ui/menu-items.tsx +9 -9
  57. package/src/components/ui/native-select.tsx +2 -2
  58. package/src/components/ui/navigation.tsx +1 -1
  59. package/src/components/ui/range-slider.tsx +5 -3
  60. package/src/components/ui/select.tsx +1 -1
  61. package/src/components/ui/sheet.tsx +1 -1
  62. package/src/components/ui/toast.tsx +8 -5
  63. package/src/components/ui/toaster.tsx +2 -2
  64. package/src/css/globals.css +28 -0
  65. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +25 -25
@@ -1,8 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { Atom } from "jotai";
3
+ import { type Atom, atom, useAtomValue } from "jotai";
4
4
  import { MapPinIcon } from "lucide-react";
5
- import React, { type PropsWithChildren, useEffect, useState } from "react";
5
+ import React, {
6
+ type PropsWithChildren,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
6
11
  import useEvent from "react-use-event-hook";
7
12
  import ReactFlow, {
8
13
  Background,
@@ -15,6 +20,7 @@ import ReactFlow, {
15
20
  useNodesState,
16
21
  useReactFlow,
17
22
  } from "reactflow";
23
+ import { edgeTypes } from "@/components/dependency-graph/custom-edge";
18
24
  import {
19
25
  EdgeMarkerContext,
20
26
  nodeTypes,
@@ -25,11 +31,16 @@ import type { CellData } from "@/core/cells/types";
25
31
  import { store } from "@/core/state/jotai";
26
32
  import type { Variables } from "@/core/variables/types";
27
33
  import { Events } from "@/utils/events";
28
- import { scrollAndHighlightCell } from "../editor/links/cell-link";
29
34
  import { Tooltip } from "../ui/tooltip";
30
- import { type NodeData, TreeElementsBuilder } from "./elements";
35
+ import {
36
+ computeDefsByCell,
37
+ type NodeData,
38
+ nodeDimensions,
39
+ TreeElementsBuilder,
40
+ } from "./elements";
31
41
  import { GraphSelectionPanel } from "./panels";
32
42
  import type { GraphSelection, GraphSettings, LayoutDirection } from "./types";
43
+ import { extractCellPreview } from "./utils/cell-preview";
33
44
  import { layoutElements } from "./utils/layout";
34
45
  import { useFitToViewOnDimensionChange } from "./utils/useFitToViewOnDimensionChange";
35
46
 
@@ -43,6 +54,24 @@ interface Props {
43
54
 
44
55
  const elementsBuilder = new TreeElementsBuilder();
45
56
 
57
+ /**
58
+ * Apply the current expand/collapse selection to freshly-built nodes, resizing
59
+ * each so the layout engine and the DOM agree on node dimensions.
60
+ */
61
+ function withExpansion(
62
+ nodes: Node<NodeData>[],
63
+ expandedIds: Set<CellId>,
64
+ ): Node<NodeData>[] {
65
+ return nodes.map((node) => {
66
+ const data: NodeData = {
67
+ ...node.data,
68
+ expanded: expandedIds.has(node.id as CellId),
69
+ };
70
+ const { width, height } = nodeDimensions(data);
71
+ return { ...node, data, width, height };
72
+ });
73
+ }
74
+
46
75
  export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
47
76
  cellIds,
48
77
  variables,
@@ -51,22 +80,23 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
51
80
  layoutDirection,
52
81
  settings,
53
82
  }) => {
83
+ // Cells whose node is expanded to show its full code (toggled by double-click).
84
+ const [expandedIds, setExpandedIds] = useState<Set<CellId>>(() => new Set());
85
+
54
86
  // oxlint-disable-next-line react/hook-use-state
55
87
  const [initial] = useState(() => {
56
- let elements = elementsBuilder.createElements(
88
+ const elements = elementsBuilder.createElements(
57
89
  cellIds,
58
90
  cellAtoms,
59
91
  variables,
60
92
  settings.hidePureMarkdown,
61
93
  settings.hideReusableFunctions,
62
94
  );
63
- elements = layoutElements({
64
- nodes: elements.nodes,
95
+ return layoutElements({
96
+ nodes: withExpansion(elements.nodes, expandedIds),
65
97
  edges: elements.edges,
66
98
  direction: layoutDirection,
67
99
  });
68
-
69
- return elements;
70
100
  // Only run once
71
101
  });
72
102
 
@@ -82,22 +112,68 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
82
112
  edges: elements.edges,
83
113
  direction: layoutDirection,
84
114
  });
85
- setNodes(result.nodes);
86
- setEdges(result.edges);
115
+ // Rebuilt elements are fresh objects; carry selection over so a
116
+ // re-layout (e.g. toggling expansion) doesn't silently clear it while
117
+ // the selection panel still targets the element.
118
+ setNodes((prev) => {
119
+ const selected = new Set(
120
+ prev.filter((node) => node.selected).map((node) => node.id),
121
+ );
122
+ return result.nodes.map((node) =>
123
+ selected.has(node.id) ? { ...node, selected: true } : node,
124
+ );
125
+ });
126
+ setEdges((prev) => {
127
+ const selected = new Set(
128
+ prev.filter((edge) => edge.selected).map((edge) => edge.id),
129
+ );
130
+ return result.edges.map((edge) =>
131
+ selected.has(edge.id) ? { ...edge, selected: true } : edge,
132
+ );
133
+ });
87
134
  },
88
135
  );
89
136
 
90
- // If the cellIds change, update the nodes.
137
+ // Node sizes depend on live cell code: the line count when expanded, or the
138
+ // preview when a cell defines nothing. The cell atoms keep their identity
139
+ // across edits, so the rebuild effect below can't see code changes through
140
+ // its deps — subscribe to the size-relevant inputs explicitly.
141
+ const defsByCell = useMemo(() => computeDefsByCell(variables), [variables]);
142
+ const sizeSignature = useAtomValue(
143
+ useMemo(
144
+ () =>
145
+ atom((get) =>
146
+ cellIds
147
+ .map((cellId, index) => {
148
+ const code = get(cellAtoms[index]).code;
149
+ if (expandedIds.has(cellId)) {
150
+ return `e${code.trim().split("\n").length}`;
151
+ }
152
+ if ((defsByCell.get(cellId) ?? []).length > 0) {
153
+ // Collapsed nodes with defs size off the defs, not the code.
154
+ return "d";
155
+ }
156
+ return `p${extractCellPreview(code).text?.length ?? 0}`;
157
+ })
158
+ .join(),
159
+ ),
160
+ [cellIds, cellAtoms, expandedIds, defsByCell],
161
+ ),
162
+ );
163
+
164
+ // Rebuild + re-layout when the graph inputs or the expand/collapse set change.
91
165
  useEffect(() => {
92
- syncChanges(
93
- elementsBuilder.createElements(
94
- cellIds,
95
- cellAtoms,
96
- variables,
97
- settings.hidePureMarkdown,
98
- settings.hideReusableFunctions,
99
- ),
166
+ const elements = elementsBuilder.createElements(
167
+ cellIds,
168
+ cellAtoms,
169
+ variables,
170
+ settings.hidePureMarkdown,
171
+ settings.hideReusableFunctions,
100
172
  );
173
+ syncChanges({
174
+ nodes: withExpansion(elements.nodes, expandedIds),
175
+ edges: elements.edges,
176
+ });
101
177
  }, [
102
178
  cellIds,
103
179
  variables,
@@ -105,6 +181,8 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
105
181
  syncChanges,
106
182
  settings.hidePureMarkdown,
107
183
  settings.hideReusableFunctions,
184
+ expandedIds,
185
+ sizeSignature,
108
186
  ]);
109
187
 
110
188
  const [selection, setSelection] = useState<GraphSelection>();
@@ -120,6 +198,7 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
120
198
  nodes={nodes}
121
199
  edges={edges}
122
200
  nodeTypes={nodeTypes}
201
+ edgeTypes={edgeTypes}
123
202
  minZoom={0.2}
124
203
  fitViewOptions={{
125
204
  minZoom: 0.5,
@@ -137,7 +216,17 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
137
216
  });
138
217
  }}
139
218
  onNodeDoubleClick={(_event, node) => {
140
- scrollAndHighlightCell(node.id as CellId, "focus");
219
+ // Expand/collapse the node to reveal its full code in place.
220
+ const id = node.id as CellId;
221
+ setExpandedIds((prev) => {
222
+ const next = new Set(prev);
223
+ if (next.has(id)) {
224
+ next.delete(id);
225
+ } else {
226
+ next.add(id);
227
+ }
228
+ return next;
229
+ });
141
230
  }}
142
231
  fitView={true}
143
232
  onNodesChange={onNodesChange}
@@ -4,26 +4,177 @@ import type { Atom } from "jotai";
4
4
  import { type Edge, MarkerType, type Node, type NodeProps } from "reactflow";
5
5
  import { getNotebook } from "@/core/cells/cells";
6
6
  import type { CellId } from "@/core/cells/ids";
7
- import type { CellData } from "@/core/cells/types";
7
+ import type { CellData, CellRuntimeState } from "@/core/cells/types";
8
8
  import { store } from "@/core/state/jotai";
9
- import type { Variables } from "@/core/variables/types";
9
+ import type { Variable, VariableName, Variables } from "@/core/variables/types";
10
10
  import { Arrays } from "@/utils/arrays";
11
+ import { extractCellPreview } from "./utils/cell-preview";
12
+
13
+ /** The edge type registered for dependency edges (see `custom-edge.tsx`). */
14
+ export const DEPENDENCY_EDGE_TYPE = "dependency";
15
+
16
+ // Stroke/marker colors shared by the dependency edge and its arrow marker.
17
+ export const STATE_FLOW_EDGE_COLOR = "var(--amber-10)";
18
+ export const DEFAULT_EDGE_COLOR = "var(--gray-8)";
19
+
20
+ export interface DependencyEdgeData {
21
+ /** Variables carried across this edge. */
22
+ variables: VariableName[];
23
+ /** True when any carried variable is a mutable `State`/`SetFunctor` flow. */
24
+ isStateFlow: boolean;
25
+ }
26
+
27
+ // A `State`/`SetFunctor` (from `mo.state`) is mutable mid-run, so it is drawn
28
+ // distinctly. `UIElement` is intentionally excluded: it is frontend-driven and
29
+ // immutable during a run.
30
+ const STATE_DATATYPES = new Set(["State", "SetFunctor"]);
31
+
32
+ export function isStateFlowVariable(variable: Variable): boolean {
33
+ return variable.dataType != null && STATE_DATATYPES.has(variable.dataType);
34
+ }
11
35
 
12
36
  export interface NodeData {
13
37
  atom: Atom<CellData>;
14
- forceWidth?: number;
38
+ /** Variables this cell defines — the node's primary identity when collapsed. */
39
+ defs: VariableName[];
40
+ /** When true the node renders its full code instead of the compact ref list. */
41
+ expanded?: boolean;
15
42
  }
16
43
  export type CustomNodeProps = NodeProps<NodeData>;
17
44
 
45
+ const LINE_HEIGHT = 11; // matches TinyCode.css
46
+
18
47
  export function getNodeHeight(linesOfCode: number) {
19
- const LINE_HEIGHT = 11; // matches TinyCode.css
20
48
  return Math.min(linesOfCode * LINE_HEIGHT + 35, 200);
21
49
  }
22
50
 
51
+ // Compact-node sizing. A collapsed node wraps its defined variables into
52
+ // comma-separated lines at a character budget, so it grows taller rather than
53
+ // wider; the same budget drives the estimated width so dagre and the DOM agree.
54
+ export const NODE_HEADER_HEIGHT = 22;
55
+ const DEF_LINE_HEIGHT = 16;
56
+ const BODY_V_PADDING = 10;
57
+ const EXPANDED_NODE_WIDTH = 300;
58
+ const CHAR_WIDTH = 6.5; // text-xs monospace-ish
59
+ const NODE_H_PADDING = 22;
60
+ const MIN_NODE_WIDTH = 90;
61
+ const MAX_NODE_WIDTH = 240;
62
+ // Wrap defs to the longest single name, but never narrower than this — short
63
+ // names pack several per line, a long one gets a line to itself.
64
+ const WRAP_MIN_CHARS = 12;
65
+ // ... and never wider than fits MAX_NODE_WIDTH, or packed lines would be
66
+ // clipped by the width clamp. A single name over this budget still gets its
67
+ // own line; the renderer truncates it with an ellipsis and a title.
68
+ const WRAP_MAX_CHARS = Math.floor(
69
+ (MAX_NODE_WIDTH - NODE_H_PADDING) / CHAR_WIDTH,
70
+ );
71
+
72
+ export function collapsedNodeWidth(label: string): number {
73
+ const raw = label.length * CHAR_WIDTH + NODE_H_PADDING;
74
+ return Math.min(Math.max(raw, MIN_NODE_WIDTH), MAX_NODE_WIDTH);
75
+ }
76
+
77
+ /** The per-line character budget used to wrap a node's defs. */
78
+ function defsWrapBudget(defs: VariableName[]): number {
79
+ const longest = defs.reduce(
80
+ (max, def) => Math.max(max, def.length),
81
+ WRAP_MIN_CHARS,
82
+ );
83
+ return Math.min(longest, WRAP_MAX_CHARS);
84
+ }
85
+
86
+ /**
87
+ * Greedily pack `defs` into comma-separated lines, each no wider than the
88
+ * budget (the longest name, or `WRAP_MIN_CHARS`). No trailing commas.
89
+ */
90
+ export function wrapDefs(defs: VariableName[]): string[] {
91
+ const budget = defsWrapBudget(defs);
92
+ const lines: string[] = [];
93
+ let current = "";
94
+ for (const def of defs) {
95
+ if (current === "") {
96
+ current = def;
97
+ } else if (current.length + 2 + def.length <= budget) {
98
+ current = `${current}, ${def}`;
99
+ } else {
100
+ lines.push(current);
101
+ current = def;
102
+ }
103
+ }
104
+ if (current !== "") {
105
+ lines.push(current);
106
+ }
107
+ return lines;
108
+ }
109
+
110
+ /**
111
+ * The lines shown in a collapsed node's body: its defs wrapped to a budget, or
112
+ * a single code-preview line when the cell defines nothing.
113
+ */
114
+ export function nodeBodyLines(data: NodeData): string[] {
115
+ if (data.defs.length > 0) {
116
+ return wrapDefs(data.defs);
117
+ }
118
+ const preview = extractCellPreview(store.get(data.atom).code).text;
119
+ return preview ? [preview] : [];
120
+ }
121
+
122
+ export function nodeDimensions(data: NodeData): {
123
+ width: number;
124
+ height: number;
125
+ } {
126
+ if (data.expanded) {
127
+ const lines = store.get(data.atom).code.trim().split("\n").length;
128
+ return { width: EXPANDED_NODE_WIDTH, height: getNodeHeight(lines) };
129
+ }
130
+ const lines = nodeBodyLines(data);
131
+ const widthChars =
132
+ data.defs.length > 0
133
+ ? defsWrapBudget(data.defs)
134
+ : lines.reduce((max, line) => Math.max(max, line.length), 0);
135
+ const bodyLines = Math.max(lines.length, 1);
136
+ return {
137
+ width: collapsedNodeWidth("x".repeat(widthChars)),
138
+ height: NODE_HEADER_HEIGHT + bodyLines * DEF_LINE_HEIGHT + BODY_V_PADDING,
139
+ };
140
+ }
141
+
23
142
  // The nodes must have the same handle IDs to ensure edges connect correctly
24
143
  export const OUTPUTS_HANDLE_ID = "outputs";
25
144
  export const INPUTS_HANDLE_ID = "inputs";
26
145
 
146
+ /**
147
+ * Cells hidden by the reusable-functions filter: those the kernel marked as
148
+ * valid top-level definitions (serialization hint `Valid`).
149
+ */
150
+ export function reusableCellIds(
151
+ cellIds: CellId[],
152
+ cellRuntime: Record<CellId, CellRuntimeState>,
153
+ ): Set<CellId> {
154
+ const reusable = new Set<CellId>();
155
+ for (const cellId of cellIds) {
156
+ if (cellRuntime[cellId]?.serialization?.toLowerCase() === "valid") {
157
+ reusable.add(cellId);
158
+ }
159
+ }
160
+ return reusable;
161
+ }
162
+
163
+ /** Map each cell to the names of the variables it declares. */
164
+ export function computeDefsByCell(
165
+ variables: Variables,
166
+ ): Map<CellId, VariableName[]> {
167
+ const defsByCell = new Map<CellId, VariableName[]>();
168
+ for (const variable of Object.values(variables)) {
169
+ for (const cellId of variable.declaredBy) {
170
+ const defs = defsByCell.get(cellId) ?? [];
171
+ defs.push(variable.name);
172
+ defsByCell.set(cellId, defs);
173
+ }
174
+ }
175
+ return defsByCell;
176
+ }
177
+
27
178
  interface ElementsBuilder {
28
179
  createElements: (
29
180
  cellIds: CellId[],
@@ -59,16 +210,17 @@ export class VerticalElementsBuilder implements ElementsBuilder {
59
210
  private createNode(
60
211
  id: string,
61
212
  atom: Atom<CellData>,
213
+ defs: VariableName[],
62
214
  prevY: number,
63
215
  ): Node<NodeData> {
64
- const linesOfCode = store.get(atom).code.trim().split("\n").length;
65
- const height = getNodeHeight(linesOfCode);
216
+ const data: NodeData = { atom, defs };
217
+ const { width, height } = nodeDimensions(data);
66
218
  return {
67
219
  id: id,
68
- data: { atom },
69
- width: 250,
220
+ data,
221
+ width,
70
222
  type: "custom",
71
- height: height,
223
+ height,
72
224
  position: { x: 0, y: prevY + 20 },
73
225
  };
74
226
  }
@@ -83,8 +235,14 @@ export class VerticalElementsBuilder implements ElementsBuilder {
83
235
  let prevY = 0;
84
236
  const nodes: Node<NodeData>[] = [];
85
237
  const edges: Edge[] = [];
238
+ const defsByCell = computeDefsByCell(variables);
86
239
  for (const [cellId, cellAtom] of Arrays.zip(cellIds, cellAtoms)) {
87
- const node = this.createNode(cellId, cellAtom, prevY);
240
+ const node = this.createNode(
241
+ cellId,
242
+ cellAtom,
243
+ defsByCell.get(cellId) ?? [],
244
+ prevY,
245
+ );
88
246
  nodes.push(node);
89
247
  prevY = node.position.y + (node.height || 0);
90
248
  }
@@ -110,16 +268,24 @@ export class VerticalElementsBuilder implements ElementsBuilder {
110
268
  }
111
269
  }
112
270
 
271
+ interface AggregatedEdge {
272
+ source: CellId;
273
+ target: CellId;
274
+ variables: VariableName[];
275
+ isStateFlow: boolean;
276
+ }
277
+
113
278
  export class TreeElementsBuilder implements ElementsBuilder {
114
- private createEdge(source: CellId, target: CellId): Edge {
279
+ private createEdge(edge: AggregatedEdge): Edge<DependencyEdgeData> {
280
+ const { source, target, variables, isStateFlow } = edge;
115
281
  return {
116
- animated: true,
282
+ type: DEPENDENCY_EDGE_TYPE,
117
283
  markerEnd: {
118
284
  type: MarkerType.ArrowClosed,
285
+ color: isStateFlow ? STATE_FLOW_EDGE_COLOR : DEFAULT_EDGE_COLOR,
119
286
  },
120
287
  id: `${source}-${target}`,
121
- // Make thicker
122
- style: { strokeWidth: 2 },
288
+ data: { variables, isStateFlow },
123
289
  source: source,
124
290
  // Use the same handle ids as the custom node
125
291
  sourceHandle: OUTPUTS_HANDLE_ID,
@@ -128,15 +294,19 @@ export class TreeElementsBuilder implements ElementsBuilder {
128
294
  };
129
295
  }
130
296
 
131
- private createNode(id: string, atom: Atom<CellData>): Node<NodeData> {
132
- const linesOfCode = store.get(atom).code.trim().split("\n").length;
133
- const height = getNodeHeight(linesOfCode);
297
+ private createNode(
298
+ id: string,
299
+ atom: Atom<CellData>,
300
+ defs: VariableName[],
301
+ ): Node<NodeData> {
302
+ const data: NodeData = { atom, defs };
303
+ const { width, height } = nodeDimensions(data);
134
304
  return {
135
305
  id: id,
136
- data: { atom, forceWidth: 300 },
137
- width: 300,
306
+ data,
307
+ width,
138
308
  type: "custom",
139
- height: height,
309
+ height,
140
310
  position: { x: 0, y: 0 },
141
311
  };
142
312
  }
@@ -149,10 +319,19 @@ export class TreeElementsBuilder implements ElementsBuilder {
149
319
  hideReusableFunctions: boolean,
150
320
  ) {
151
321
  const nodes: Node<NodeData>[] = [];
152
- const edges: Edge[] = [];
322
+
323
+ const cellRuntime = getNotebook().cellRuntime;
324
+ // Hidden reusable cells are dropped along with their edges — a reusable
325
+ // function is nearly always referenced somewhere, so gating on "no edges"
326
+ // would make the filter a no-op.
327
+ const hiddenReusable = hideReusableFunctions
328
+ ? reusableCellIds(cellIds, cellRuntime)
329
+ : new Set<CellId>();
153
330
 
154
331
  const nodesWithEdges = new Set<CellId>();
155
- const visited = new Set<string>();
332
+ // Dedupe cell→cell edges, aggregating every variable that crosses them so a
333
+ // single edge can carry multiple refs and flag whether any is a state flow.
334
+ const edgesByPair = new Map<string, AggregatedEdge>();
156
335
 
157
336
  for (const variable of Object.values(variables)) {
158
337
  // Skip marimo, since likely every cell uses it
@@ -160,40 +339,55 @@ export class TreeElementsBuilder implements ElementsBuilder {
160
339
  continue;
161
340
  }
162
341
 
342
+ const isState = isStateFlowVariable(variable);
163
343
  const { declaredBy, usedBy } = variable;
164
344
  for (const fromId of declaredBy) {
165
345
  for (const toId of usedBy) {
166
- const key = `${fromId}-${toId}`;
167
- if (visited.has(key)) {
346
+ if (hiddenReusable.has(fromId) || hiddenReusable.has(toId)) {
168
347
  continue;
169
348
  }
170
- visited.add(key);
349
+ const key = `${fromId}-${toId}`;
350
+ const existing = edgesByPair.get(key);
351
+ if (existing) {
352
+ existing.variables.push(variable.name);
353
+ existing.isStateFlow ||= isState;
354
+ } else {
355
+ edgesByPair.set(key, {
356
+ source: fromId,
357
+ target: toId,
358
+ variables: [variable.name],
359
+ isStateFlow: isState,
360
+ });
361
+ }
171
362
  nodesWithEdges.add(fromId);
172
363
  nodesWithEdges.add(toId);
173
- edges.push(this.createEdge(fromId, toId));
174
364
  }
175
365
  }
176
366
  }
177
367
 
178
- const cellRuntime = getNotebook().cellRuntime;
368
+ const edges = [...edgesByPair.values()].map((edge) =>
369
+ this.createEdge(edge),
370
+ );
371
+
372
+ const defsByCell = computeDefsByCell(variables);
179
373
 
180
374
  for (const [cellId, cellAtom] of Arrays.zip(cellIds, cellAtoms)) {
181
375
  const code = store.get(cellAtom).code.trim();
182
376
  const hasEdge = nodesWithEdges.has(cellId);
183
377
  const isMarkdown = code.startsWith("mo.md");
184
- const runtime = cellRuntime[cellId];
185
- const isReusable = runtime?.serialization?.toLowerCase() === "valid";
186
378
 
187
379
  // Apply filters
188
380
  if (hidePureMarkdown && isMarkdown && !hasEdge) {
189
381
  continue;
190
382
  }
191
- if (hideReusableFunctions && isReusable && !hasEdge) {
383
+ if (hiddenReusable.has(cellId)) {
192
384
  continue;
193
385
  }
194
386
 
195
387
  // Show every cell that wasn't filtered out
196
- nodes.push(this.createNode(cellId, cellAtom));
388
+ nodes.push(
389
+ this.createNode(cellId, cellAtom, defsByCell.get(cellId) ?? []),
390
+ );
197
391
  }
198
392
 
199
393
  return { nodes, edges };
@@ -4,6 +4,7 @@ import {
4
4
  ArrowRightFromLineIcon,
5
5
  ArrowRightIcon,
6
6
  ArrowRightToLineIcon,
7
+ ExternalLinkIcon,
7
8
  MoreVerticalIcon,
8
9
  NetworkIcon,
9
10
  SettingsIcon,
@@ -13,17 +14,18 @@ import {
13
14
  } from "lucide-react";
14
15
  import React, { memo, useId } from "react";
15
16
  import { type Edge, Panel } from "reactflow";
16
- import { getCellEditorView } from "@/core/cells/cells";
17
+ import { getCellEditorView, useCellActions } from "@/core/cells/cells";
17
18
  import type { CellId } from "@/core/cells/ids";
18
19
  import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
19
20
  import type { Variable, Variables } from "@/core/variables/types";
20
21
  import { ConnectionCellActionsDropdown } from "../editor/cell/cell-actions";
21
- import { CellLink } from "../editor/links/cell-link";
22
+ import { CellLink, scrollAndHighlightCell } from "../editor/links/cell-link";
22
23
  import { CellLinkList } from "../editor/links/cell-link-list";
23
24
  import { Button } from "../ui/button";
24
25
  import { Checkbox } from "../ui/checkbox";
25
26
  import { Label } from "../ui/label";
26
27
  import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
28
+ import { Tooltip } from "../ui/tooltip";
27
29
  import { VariableName } from "../variables/common";
28
30
  import type { GraphSelection, GraphSettings, LayoutDirection } from "./types";
29
31
 
@@ -122,6 +124,8 @@ export const GraphSelectionPanel: React.FC<{
122
124
  edges: Edge[];
123
125
  variables: Variables;
124
126
  }> = memo(({ selection, variables, onClearSelection }) => {
127
+ const { showCellIfHidden } = useCellActions();
128
+
125
129
  if (!selection) {
126
130
  return null;
127
131
  }
@@ -194,6 +198,23 @@ export const GraphSelectionPanel: React.FC<{
194
198
  <SquareFunction className="w-5 h-5" />
195
199
  <CellLink cellId={selection.id} />
196
200
  <div className="flex-1" />
201
+ <Tooltip content="Open cell in editor" delayDuration={200}>
202
+ <Button
203
+ aria-label="Open cell in editor"
204
+ variant="text"
205
+ size="icon"
206
+ onClick={() => {
207
+ // The cell may sit in a collapsed section; reveal it first
208
+ // or the scroll target won't be in the DOM.
209
+ showCellIfHidden({ cellId: selection.id });
210
+ requestAnimationFrame(() => {
211
+ scrollAndHighlightCell(selection.id, "focus");
212
+ });
213
+ }}
214
+ >
215
+ <ExternalLinkIcon className="w-4 h-4" />
216
+ </Button>
217
+ </Tooltip>
197
218
  <ConnectionCellActionsDropdown cellId={selection.id}>
198
219
  <Button variant="ghost" size="icon">
199
220
  <MoreVerticalIcon className="w-4 h-4" />