Skip to content

Commit 958c19d

Browse files
authored
Add reactive notebook minimap for navigating and visualizing dataflow (#5707)
1 parent 235b129 commit 958c19d

File tree

14 files changed

+1203
-45
lines changed

14 files changed

+1203
-45
lines changed

frontend/src/components/data-table/hooks/use-panel-ownership.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
isCellAwareAtom,
88
} from "@/components/editor/chrome/panels/context-aware-panel/atoms";
99
import type { PanelType } from "@/components/editor/chrome/panels/context-aware-panel/context-aware-panel";
10-
import { lastFocusedCellIdAtom } from "@/core/cells/focus";
10+
import { useCellFocusActions, useLastFocusedCellId } from "@/core/cells/focus";
1111
import type { CellId } from "@/core/cells/ids";
1212
import { Logger } from "@/utils/Logger";
1313

@@ -21,9 +21,8 @@ export function usePanelOwnership(
2121
cellId?: CellId | null,
2222
): PanelOwnershipResult {
2323
let isPanelCellAware = useAtomValue(isCellAwareAtom);
24-
const [lastFocusedCellId, setLastFocusedCellId] = useAtom(
25-
lastFocusedCellIdAtom,
26-
);
24+
const { focusCell } = useCellFocusActions();
25+
const lastFocusedCellId = useLastFocusedCellId();
2726
const [panelType, setPanelType] = useAtom(contextAwarePanelType);
2827
const [panelOwner, setPanelOwner] = useAtom(contextAwarePanelOwner);
2928
const [isContextAwarePanelOpen, setContextAwarePanelOpen] = useAtom(
@@ -64,7 +63,7 @@ export function usePanelOwnership(
6463
setPanelOwner(panelId);
6564
// if cell-aware, we want to focus on this cell when toggled open
6665
if (isPanelCellAware && cellId) {
67-
setLastFocusedCellId(cellId);
66+
focusCell({ cellId });
6867
}
6968
setContextAwarePanelOpen(true);
7069
setPanelType(panelType);

frontend/src/components/editor/chrome/state.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface ChromeState {
1010
selectedPanel: PanelType | undefined;
1111
isSidebarOpen: boolean;
1212
isTerminalOpen: boolean;
13+
isMinimapOpen: boolean;
1314
}
1415

1516
const KEY = "marimo:sidebar";
@@ -21,6 +22,7 @@ const storage = new ZodLocalStorage<ChromeState>(
2122
.transform((v) => v as PanelType),
2223
isSidebarOpen: z.boolean(),
2324
isTerminalOpen: z.boolean(),
25+
isMinimapOpen: z.boolean(),
2426
}),
2527
initialState,
2628
);
@@ -30,6 +32,7 @@ function initialState(): ChromeState {
3032
selectedPanel: "variables", // initial panel
3133
isSidebarOpen: false,
3234
isTerminalOpen: false,
35+
isMinimapOpen: false,
3336
};
3437
}
3538

@@ -71,6 +74,14 @@ const {
7174
...state,
7275
isTerminalOpen: isOpen,
7376
}),
77+
toggleMinimap: (state) => ({
78+
...state,
79+
isMinimapOpen: !state.isMinimapOpen,
80+
}),
81+
setIsMinimapOpen: (state, isOpen: boolean) => ({
82+
...state,
83+
isMinimapOpen: isOpen,
84+
}),
7485
},
7586
[(_prevState, newState) => storage.set(KEY, newState)],
7687
);
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
import { describe, expect, it } from "vitest";
3+
import type { CellId } from "@/core/cells/ids";
4+
import type { VariableName, Variables } from "@/core/variables/types";
5+
import { buildCellGraph } from "../minimap-state";
6+
7+
const cellId = (id: string) => id as CellId;
8+
const varName = (name: string) => name as VariableName;
9+
10+
describe("buildCellGraph", () => {
11+
it("builds graph for simple linear dependency", () => {
12+
// cell1 declares x, cell2 uses x
13+
const cellIds = [cellId("cell1"), cellId("cell2")];
14+
const variables: Variables = {
15+
[varName("x")]: {
16+
name: varName("x"),
17+
declaredBy: [cellId("cell1")],
18+
usedBy: [cellId("cell2")],
19+
},
20+
};
21+
22+
const graph = buildCellGraph(cellIds, variables);
23+
24+
expect(graph).toMatchInlineSnapshot(`
25+
{
26+
"cell1": {
27+
"ancestors": Set {},
28+
"children": Set {
29+
"cell2",
30+
},
31+
"descendants": Set {
32+
"cell2",
33+
},
34+
"parents": Set {},
35+
"variables": [
36+
"x",
37+
],
38+
},
39+
"cell2": {
40+
"ancestors": Set {
41+
"cell1",
42+
},
43+
"children": Set {},
44+
"descendants": Set {},
45+
"parents": Set {
46+
"cell1",
47+
},
48+
"variables": [],
49+
},
50+
}
51+
`);
52+
});
53+
54+
it("builds graph for diamond pattern", () => {
55+
// cell1 declares x
56+
// cell2 and cell3 both use x and declare y and z
57+
// cell4 uses y and z
58+
const cellIds = [
59+
cellId("cell1"),
60+
cellId("cell2"),
61+
cellId("cell3"),
62+
cellId("cell4"),
63+
];
64+
const variables: Variables = {
65+
[varName("x")]: {
66+
name: varName("x"),
67+
declaredBy: [cellId("cell1")],
68+
usedBy: [cellId("cell2"), cellId("cell3")],
69+
},
70+
[varName("y")]: {
71+
name: varName("y"),
72+
declaredBy: [cellId("cell2")],
73+
usedBy: [cellId("cell4")],
74+
},
75+
[varName("z")]: {
76+
name: varName("z"),
77+
declaredBy: [cellId("cell3")],
78+
usedBy: [cellId("cell4")],
79+
},
80+
};
81+
82+
const graph = buildCellGraph(cellIds, variables);
83+
84+
expect(graph).toMatchInlineSnapshot(`
85+
{
86+
"cell1": {
87+
"ancestors": Set {},
88+
"children": Set {
89+
"cell2",
90+
"cell3",
91+
},
92+
"descendants": Set {
93+
"cell2",
94+
"cell4",
95+
"cell3",
96+
},
97+
"parents": Set {},
98+
"variables": [
99+
"x",
100+
],
101+
},
102+
"cell2": {
103+
"ancestors": Set {
104+
"cell1",
105+
},
106+
"children": Set {
107+
"cell4",
108+
},
109+
"descendants": Set {
110+
"cell4",
111+
},
112+
"parents": Set {
113+
"cell1",
114+
},
115+
"variables": [
116+
"y",
117+
],
118+
},
119+
"cell3": {
120+
"ancestors": Set {
121+
"cell1",
122+
},
123+
"children": Set {
124+
"cell4",
125+
},
126+
"descendants": Set {
127+
"cell4",
128+
},
129+
"parents": Set {
130+
"cell1",
131+
},
132+
"variables": [
133+
"z",
134+
],
135+
},
136+
"cell4": {
137+
"ancestors": Set {
138+
"cell2",
139+
"cell1",
140+
"cell3",
141+
},
142+
"children": Set {},
143+
"descendants": Set {},
144+
"parents": Set {
145+
"cell2",
146+
"cell3",
147+
},
148+
"variables": [],
149+
},
150+
}
151+
`);
152+
});
153+
154+
it("handles self-referencing and isolated cells", () => {
155+
const cellIds = [cellId("cell1"), cellId("cell2"), cellId("cell3")];
156+
const variables: Variables = {
157+
[varName("x")]: {
158+
name: varName("x"),
159+
declaredBy: [cellId("cell1")],
160+
usedBy: [cellId("cell1"), cellId("cell2")], // self-reference + downstream
161+
},
162+
[varName("y")]: {
163+
name: varName("y"),
164+
declaredBy: [cellId("cell3")],
165+
usedBy: [], // isolated variable
166+
},
167+
};
168+
169+
const graph = buildCellGraph(cellIds, variables);
170+
171+
expect(graph).toMatchInlineSnapshot(`
172+
{
173+
"cell1": {
174+
"ancestors": Set {},
175+
"children": Set {
176+
"cell2",
177+
},
178+
"descendants": Set {
179+
"cell2",
180+
},
181+
"parents": Set {},
182+
"variables": [
183+
"x",
184+
],
185+
},
186+
"cell2": {
187+
"ancestors": Set {
188+
"cell1",
189+
},
190+
"children": Set {},
191+
"descendants": Set {},
192+
"parents": Set {
193+
"cell1",
194+
},
195+
"variables": [],
196+
},
197+
"cell3": {
198+
"ancestors": Set {},
199+
"children": Set {},
200+
"descendants": Set {},
201+
"parents": Set {},
202+
"variables": [
203+
"y",
204+
],
205+
},
206+
}
207+
`);
208+
});
209+
});

frontend/src/components/editor/chrome/wrapper/app-chrome.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { SnippetsPanel } from "../panels/snippets-panel";
3232
import { TracingPanel } from "../panels/tracing-panel";
3333
import { VariablePanel } from "../panels/variable-panel";
3434
import { useChromeActions, useChromeState } from "../state";
35+
import { Minimap } from "./minimap";
3536
import { PanelsWrapper } from "./panels";
3637
import { createStorage } from "./storage";
3738
import { handleDragging } from "./utils";
@@ -244,6 +245,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
244245
</Panel>
245246
<ContextAwarePanel />
246247
</PanelGroup>
248+
<Minimap />
247249
<ErrorBoundary>
248250
<TooltipProvider>
249251
<Footer />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { MapIcon } from "lucide-react";
4+
import { useChromeActions, useChromeState } from "../../state";
5+
import { FooterItem } from "../footer-item";
6+
7+
export const MinimapStatusIcon: React.FC = () => {
8+
const { isMinimapOpen } = useChromeState();
9+
const { toggleMinimap } = useChromeActions();
10+
11+
return (
12+
<FooterItem
13+
tooltip="Toggle Minimap"
14+
selected={isMinimapOpen}
15+
onClick={() => toggleMinimap()}
16+
data-testid="footer-minimap"
17+
>
18+
<MapIcon className="h-4 w-4" />
19+
</FooterItem>
20+
);
21+
};

frontend/src/components/editor/chrome/wrapper/footer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AIStatusIcon } from "./footer-items/ai-status";
1717
import { BackendConnection } from "./footer-items/backend-status";
1818
import { CopilotStatusIcon } from "./footer-items/copilot-status";
1919
import { MachineStats } from "./footer-items/machine-stats";
20+
import { MinimapStatusIcon } from "./footer-items/minimap-status";
2021
import { RTCStatus } from "./footer-items/rtc-status";
2122
import { RuntimeSettings } from "./footer-items/runtime-settings";
2223

@@ -78,6 +79,7 @@ export const Footer: React.FC = () => {
7879

7980
<div className="flex items-center flex-shrink-0 min-w-0">
8081
<MachineStats />
82+
<MinimapStatusIcon />
8183
<AIStatusIcon />
8284
<CopilotStatusIcon />
8385
<RTCStatus />

0 commit comments

Comments
 (0)