Skip to content

Commit a77ea94

Browse files
authored
de: Add tabs to Data Explorer (#4994)
Add multi-tab support to the Data Explorer, allowing users to work on multiple independent graph explorations simultaneously. Each tab maintains its own state (nodes, layout, history, materialization) and tabs persist across page reloads via localStorage and permalinks. ## Changes - Refactored `ExplorePage` component to render multiple tabs using the existing `Tabs` widget, with per-tab services (query execution, cleanup, history) - Added `explore_tabs_storage.ts` for persisting tab layout to localStorage using zod schema validation - Updated plugin (`index.ts`) to manage an array of tabs instead of a single state, with debounced saves and backward compatibility for v1 single-graph permalinks and recentGraphsStorage - Fixed SVG marker ID collisions in `nodegraph.ts` when multiple NodeGraph instances exist (e.g. across tabs) by using instance-unique IDs and scoped DOM queries - Added `onTabDblClick` callback to the `Tabs` widget for tab renaming - Registered a `beforeunload` handler to flush pending saves on page unload
1 parent e939f99 commit a77ea94

File tree

10 files changed

+1005
-465
lines changed

10 files changed

+1005
-465
lines changed

ui/src/plugins/dev.perfetto.ExplorePage/clipboard_operations.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ interface CopyableState {
4646
interface PastableState {
4747
readonly rootNodes: QueryNode[];
4848
readonly nodeLayouts: Map<string, {x: number; y: number}>;
49-
readonly clipboardNodes?: ClipboardEntry[];
50-
readonly clipboardConnections?: ClipboardConnection[];
5149
}
5250

5351
// Copies the currently selected nodes and their internal connections to a
@@ -128,19 +126,22 @@ export function copySelectedNodes(
128126

129127
// Pastes clipboard nodes into the state. Returns the updated state fields
130128
// with new nodes added, or undefined if clipboard is empty.
131-
export function pasteClipboardNodes(state: PastableState):
129+
export function pasteClipboardNodes(
130+
state: PastableState,
131+
clipboard: ClipboardResult | undefined,
132+
):
132133
| {
133134
rootNodes: QueryNode[];
134135
selectedNodes: Set<string>;
135136
nodeLayouts: Map<string, {x: number; y: number}>;
136137
}
137138
| undefined {
138-
if (state.clipboardNodes === undefined || state.clipboardNodes.length === 0) {
139+
if (clipboard === undefined || clipboard.clipboardNodes.length === 0) {
139140
return undefined;
140141
}
141142

142143
// Clone nodes again for this paste operation (allows multiple pastes)
143-
const newNodes = state.clipboardNodes.map((entry) => entry.node.clone());
144+
const newNodes = clipboard.clipboardNodes.map((entry) => entry.node.clone());
144145

145146
// Calculate paste offset (place slightly offset from original)
146147
const pasteOffsetX = 50;
@@ -149,7 +150,7 @@ export function pasteClipboardNodes(state: PastableState):
149150
// Update layouts for new nodes - only add layouts for undocked nodes
150151
// Docked nodes will remain docked (attached to their parent)
151152
const updatedLayouts = new Map(state.nodeLayouts);
152-
state.clipboardNodes.forEach((entry, index) => {
153+
clipboard.clipboardNodes.forEach((entry, index) => {
153154
if (!entry.isDocked) {
154155
updatedLayouts.set(newNodes[index].nodeId, {
155156
x: entry.relativeX + pasteOffsetX,
@@ -159,13 +160,11 @@ export function pasteClipboardNodes(state: PastableState):
159160
});
160161

161162
// Restore connections between pasted nodes
162-
if (state.clipboardConnections) {
163-
for (const conn of state.clipboardConnections) {
164-
const fromNode = newNodes[conn.fromIndex] as QueryNode | undefined;
165-
const toNode = newNodes[conn.toIndex] as QueryNode | undefined;
166-
if (fromNode !== undefined && toNode !== undefined) {
167-
addConnection(fromNode, toNode, conn.portIndex);
168-
}
163+
for (const conn of clipboard.clipboardConnections) {
164+
const fromNode = newNodes[conn.fromIndex] as QueryNode | undefined;
165+
const toNode = newNodes[conn.toIndex] as QueryNode | undefined;
166+
if (fromNode !== undefined && toNode !== undefined) {
167+
addConnection(fromNode, toNode, conn.portIndex);
169168
}
170169
}
171170

ui/src/plugins/dev.perfetto.ExplorePage/clipboard_operations_unittest.ts

Lines changed: 89 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -175,36 +175,36 @@ describe('clipboard_operations', () => {
175175
});
176176

177177
describe('pasteClipboardNodes', () => {
178-
it('should return undefined when clipboard is empty', () => {
179-
const result = pasteClipboardNodes({
180-
rootNodes: [],
181-
nodeLayouts: new Map(),
182-
clipboardNodes: undefined,
183-
});
178+
it('should return undefined when clipboard is undefined', () => {
179+
const result = pasteClipboardNodes(
180+
{rootNodes: [], nodeLayouts: new Map()},
181+
undefined,
182+
);
184183
expect(result).toBeUndefined();
185184
});
186185

187186
it('should return undefined when clipboard has zero entries', () => {
188-
const result = pasteClipboardNodes({
189-
rootNodes: [],
190-
nodeLayouts: new Map(),
191-
clipboardNodes: [],
192-
});
187+
const result = pasteClipboardNodes(
188+
{rootNodes: [], nodeLayouts: new Map()},
189+
{clipboardNodes: [], clipboardConnections: []},
190+
);
193191
expect(result).toBeUndefined();
194192
});
195193

196194
it('should append cloned nodes to rootNodes', () => {
197195
const existing = createMockNode({nodeId: 'existing'});
198196
const clipNode = createMockNode({nodeId: 'clip'});
199-
const clipboardNodes: ClipboardEntry[] = [
200-
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
201-
];
197+
const clipboard = {
198+
clipboardNodes: [
199+
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
200+
] as ClipboardEntry[],
201+
clipboardConnections: [] as ClipboardConnection[],
202+
};
202203

203-
const result = pasteClipboardNodes({
204-
rootNodes: [existing],
205-
nodeLayouts: new Map(),
206-
clipboardNodes,
207-
});
204+
const result = pasteClipboardNodes(
205+
{rootNodes: [existing], nodeLayouts: new Map()},
206+
clipboard,
207+
);
208208

209209
expect(result).toBeDefined();
210210
// Original node + pasted node
@@ -216,15 +216,17 @@ describe('clipboard_operations', () => {
216216

217217
it('should select only the newly pasted nodes', () => {
218218
const clipNode = createMockNode({nodeId: 'clip'});
219-
const clipboardNodes: ClipboardEntry[] = [
220-
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
221-
];
219+
const clipboard = {
220+
clipboardNodes: [
221+
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
222+
] as ClipboardEntry[],
223+
clipboardConnections: [] as ClipboardConnection[],
224+
};
222225

223-
const result = pasteClipboardNodes({
224-
rootNodes: [],
225-
nodeLayouts: new Map(),
226-
clipboardNodes,
227-
});
226+
const result = pasteClipboardNodes(
227+
{rootNodes: [], nodeLayouts: new Map()},
228+
clipboard,
229+
);
228230

229231
expect(result).toBeDefined();
230232
expect(result?.selectedNodes.size).toBe(1);
@@ -235,15 +237,17 @@ describe('clipboard_operations', () => {
235237

236238
it('should add layout positions for undocked nodes with offset', () => {
237239
const clipNode = createMockNode({nodeId: 'clip'});
238-
const clipboardNodes: ClipboardEntry[] = [
239-
{node: clipNode, relativeX: 100, relativeY: 200, isDocked: false},
240-
];
240+
const clipboard = {
241+
clipboardNodes: [
242+
{node: clipNode, relativeX: 100, relativeY: 200, isDocked: false},
243+
] as ClipboardEntry[],
244+
clipboardConnections: [] as ClipboardConnection[],
245+
};
241246

242-
const result = pasteClipboardNodes({
243-
rootNodes: [],
244-
nodeLayouts: new Map(),
245-
clipboardNodes,
246-
});
247+
const result = pasteClipboardNodes(
248+
{rootNodes: [], nodeLayouts: new Map()},
249+
clipboard,
250+
);
247251

248252
expect(result).toBeDefined();
249253
const pastedNodeId = result?.rootNodes[0].nodeId ?? '';
@@ -256,15 +260,17 @@ describe('clipboard_operations', () => {
256260

257261
it('should not add layout for docked nodes', () => {
258262
const clipNode = createMockNode({nodeId: 'clip'});
259-
const clipboardNodes: ClipboardEntry[] = [
260-
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: true},
261-
];
263+
const clipboard = {
264+
clipboardNodes: [
265+
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: true},
266+
] as ClipboardEntry[],
267+
clipboardConnections: [] as ClipboardConnection[],
268+
};
262269

263-
const result = pasteClipboardNodes({
264-
rootNodes: [],
265-
nodeLayouts: new Map(),
266-
clipboardNodes,
267-
});
270+
const result = pasteClipboardNodes(
271+
{rootNodes: [], nodeLayouts: new Map()},
272+
clipboard,
273+
);
268274

269275
expect(result).toBeDefined();
270276
const pastedNodeId = result?.rootNodes[0].nodeId ?? '';
@@ -283,21 +289,25 @@ describe('clipboard_operations', () => {
283289
sqlModules: mockSqlModules,
284290
});
285291

286-
const clipboardNodes: ClipboardEntry[] = [
287-
{node: realNode, relativeX: 0, relativeY: 0, isDocked: false},
288-
];
292+
const clipboard = {
293+
clipboardNodes: [
294+
{node: realNode, relativeX: 0, relativeY: 0, isDocked: false},
295+
] as ClipboardEntry[],
296+
clipboardConnections: [] as ClipboardConnection[],
297+
};
289298

290-
const result1 = pasteClipboardNodes({
291-
rootNodes: [],
292-
nodeLayouts: new Map(),
293-
clipboardNodes,
294-
});
299+
const result1 = pasteClipboardNodes(
300+
{rootNodes: [], nodeLayouts: new Map()},
301+
clipboard,
302+
);
295303

296-
const result2 = pasteClipboardNodes({
297-
rootNodes: result1?.rootNodes ?? [],
298-
nodeLayouts: result1?.nodeLayouts ?? new Map(),
299-
clipboardNodes,
300-
});
304+
const result2 = pasteClipboardNodes(
305+
{
306+
rootNodes: result1?.rootNodes ?? [],
307+
nodeLayouts: result1?.nodeLayouts ?? new Map(),
308+
},
309+
clipboard,
310+
);
301311

302312
expect(result2).toBeDefined();
303313
expect(result2?.rootNodes).toHaveLength(2);
@@ -310,20 +320,18 @@ describe('clipboard_operations', () => {
310320
it('should restore connections between pasted nodes', () => {
311321
const parent = createMockNode({nodeId: 'p', type: NodeType.kTable});
312322
const child = createMockNode({nodeId: 'c', type: NodeType.kFilter});
313-
const clipboardNodes: ClipboardEntry[] = [
314-
{node: parent, relativeX: 0, relativeY: 0, isDocked: false},
315-
{node: child, relativeX: 0, relativeY: 100, isDocked: false},
316-
];
317-
const clipboardConnections: ClipboardConnection[] = [
318-
{fromIndex: 0, toIndex: 1},
319-
];
320-
321-
const result = pasteClipboardNodes({
322-
rootNodes: [],
323-
nodeLayouts: new Map(),
324-
clipboardNodes,
325-
clipboardConnections,
326-
});
323+
const clipboard = {
324+
clipboardNodes: [
325+
{node: parent, relativeX: 0, relativeY: 0, isDocked: false},
326+
{node: child, relativeX: 0, relativeY: 100, isDocked: false},
327+
] as ClipboardEntry[],
328+
clipboardConnections: [{fromIndex: 0, toIndex: 1}],
329+
};
330+
331+
const result = pasteClipboardNodes(
332+
{rootNodes: [], nodeLayouts: new Map()},
333+
clipboard,
334+
);
327335

328336
expect(result).toBeDefined();
329337
expect(result?.rootNodes).toHaveLength(2);
@@ -337,15 +345,17 @@ describe('clipboard_operations', () => {
337345
const existing = createMockNode({nodeId: 'existing'});
338346
const clipNode = createMockNode({nodeId: 'clip'});
339347
const existingLayouts = new Map([['existing', {x: 500, y: 600}]]);
340-
const clipboardNodes: ClipboardEntry[] = [
341-
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
342-
];
343-
344-
const result = pasteClipboardNodes({
345-
rootNodes: [existing],
346-
nodeLayouts: existingLayouts,
347-
clipboardNodes,
348-
});
348+
const clipboard = {
349+
clipboardNodes: [
350+
{node: clipNode, relativeX: 0, relativeY: 0, isDocked: false},
351+
] as ClipboardEntry[],
352+
clipboardConnections: [] as ClipboardConnection[],
353+
};
354+
355+
const result = pasteClipboardNodes(
356+
{rootNodes: [existing], nodeLayouts: existingLayouts},
357+
clipboard,
358+
);
349359

350360
expect(result).toBeDefined();
351361
expect(result?.nodeLayouts.get('existing')).toEqual({x: 500, y: 600});

0 commit comments

Comments
 (0)