Skip to content

Commit 9884793

Browse files
authored
exp: Undo/redo (#3561)
## Summary Adds undo/redo functionality to the Explore Page, allowing users to navigate through their query building history. Users can now undo and redo actions using keyboard shortcuts (Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y) or clickable buttons in the UI. ## Changes ### New Features - **History tracking**: All state changes are automatically tracked, including node creation, deletion, connection changes, and property modifications - **Keyboard shortcuts**: - `Ctrl+Z` (or `Cmd+Z` on Mac) for undo - `Ctrl+Shift+Z` or `Ctrl+Y` for redo - **Visual controls**: Floating undo/redo buttons in the bottom-right corner with enabled/disabled states - **Smart filtering**: Layout-only changes (dragging nodes) are excluded from history to avoid polluting the undo stack - **Granular tracking**: Individual property changes (e.g., selecting group by columns) are captured as separate history entries ### Implementation Details #### Core Components - **`HistoryManager`** (`history_manager.ts`): Manages history stack with: - Max history size of 10 states to prevent unbounded memory growth - State serialization for comparison (excluding layout changes) - Undo/redo operations with circular history prevention - **Integration** (`explore_page.ts`): - Wraps `onStateUpdate` callback to intercept all state changes - Handles keyboard shortcuts at the page level - Prevents recording of undo/redo-triggered changes #### UI Components - Added undo/redo buttons to query builder (`builder.ts`) - Buttons show enabled/disabled state based on history availability - Positioned in bottom-right corner with floating style (`builder.scss`) ### Testing - Comprehensive unit tests in `history_manager_unittest.ts` covering: - Basic undo/redo operations - Granular property change tracking - Layout-only change filtering - Redo stack clearing on new actions - History size limits - Complex node graphs with multiple connections ## Test plan ### Manual Testing 1. **Basic undo/redo**: - Open Explore Page - Add a table source node - Press Ctrl+Z → node should disappear - Press Ctrl+Shift+Z → node should reappear 2. **Granular changes**: - Create a table source node - Add an aggregation node - Check first group by column - Check second group by column - Press Ctrl+Z twice → both columns should uncheck in reverse order 3. **Layout filtering**: - Add a node - Drag it to a different position - Press Ctrl+Z → node position should stay, but the node itself should disappear (proving layout changes don't create history entries) 4. **UI buttons**: - Verify undo/redo buttons appear in bottom-right corner - Verify buttons are disabled when no undo/redo is available - Click buttons to undo/redo actions 5. **History limit**: - Perform more than 10 distinct actions - Attempt to undo → should only undo last 10 actions 6. **Redo stack clearing**: - Add a node - Press Ctrl+Z (undo) - Add a different node - Press Ctrl+Shift+Z → redo should not be available ### Automated Testing ```bash ui/run-unittests history_manager_unittest
1 parent 518d8e8 commit 9884793

20 files changed

+969
-35
lines changed

ui/src/base/semantic_icons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class Icons {
5151
static readonly Search = 'search';
5252
static readonly Save = 'save';
5353
static readonly Undo = 'undo';
54+
static readonly Redo = 'redo';
5455

5556
// Page control
5657
static readonly NextPage = 'chevron_right';

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

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {exportStateAsJson, importStateFromJson} from './json_handler';
2828
import {showImportWithStatementModal} from './sql_json_handler';
2929
import {registerCoreNodes} from './query_builder/core_nodes';
3030
import {nodeRegistry} from './query_builder/node_registry';
31+
import {HistoryManager} from './history_manager';
3132

3233
registerCoreNodes();
3334

@@ -50,6 +51,8 @@ interface ExplorePageAttrs {
5051
}
5152

5253
export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
54+
private historyManager?: HistoryManager;
55+
5356
private selectNode(attrs: ExplorePageAttrs, node: QueryNode) {
5457
attrs.onStateUpdate((currentState) => ({
5558
...currentState,
@@ -271,7 +274,7 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
271274
parentNodes.push(node.prevNode);
272275
} else if ('prevNodes' in node) {
273276
for (const prevNode of node.prevNodes) {
274-
if (prevNode) parentNodes.push(prevNode);
277+
if (prevNode !== undefined) parentNodes.push(prevNode);
275278
}
276279
}
277280

@@ -386,6 +389,28 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
386389
return;
387390
}
388391

392+
// Handle undo/redo shortcuts
393+
if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
394+
if (event.shiftKey) {
395+
// Ctrl+Shift+Z or Cmd+Shift+Z for Redo
396+
this.handleRedo(attrs);
397+
event.preventDefault();
398+
return;
399+
} else {
400+
// Ctrl+Z or Cmd+Z for Undo
401+
this.handleUndo(attrs);
402+
event.preventDefault();
403+
return;
404+
}
405+
}
406+
407+
// Also support Ctrl+Y for Redo on Windows/Linux
408+
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
409+
this.handleRedo(attrs);
410+
event.preventDefault();
411+
return;
412+
}
413+
389414
// Handle source node creation shortcuts
390415
for (const [id, descriptor] of nodeRegistry.list()) {
391416
if (
@@ -418,6 +443,24 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
418443
showImportWithStatementModal(trace, sqlModules, onStateUpdate);
419444
}
420445

446+
private handleUndo(attrs: ExplorePageAttrs) {
447+
if (!this.historyManager) return;
448+
449+
const previousState = this.historyManager.undo();
450+
if (previousState) {
451+
attrs.onStateUpdate(previousState);
452+
}
453+
}
454+
455+
private handleRedo(attrs: ExplorePageAttrs) {
456+
if (!this.historyManager) return;
457+
458+
const nextState = this.historyManager.redo();
459+
if (nextState) {
460+
attrs.onStateUpdate(nextState);
461+
}
462+
}
463+
421464
view({attrs}: m.CVnode<ExplorePageAttrs>) {
422465
const {trace, state} = attrs;
423466

@@ -433,10 +476,38 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
433476
);
434477
}
435478

479+
// Initialize history manager if not already done
480+
if (!this.historyManager) {
481+
this.historyManager = new HistoryManager(trace, sqlModules);
482+
// Push initial state
483+
this.historyManager.pushState(state);
484+
}
485+
486+
// Wrap onStateUpdate to track history
487+
const wrappedOnStateUpdate = (
488+
update:
489+
| ExplorePageState
490+
| ((currentState: ExplorePageState) => ExplorePageState),
491+
) => {
492+
attrs.onStateUpdate((currentState) => {
493+
const newState =
494+
typeof update === 'function' ? update(currentState) : update;
495+
// Push state to history after update
496+
this.historyManager?.pushState(newState);
497+
return newState;
498+
});
499+
};
500+
501+
// Create wrapped attrs to track history
502+
const wrappedAttrs = {
503+
...attrs,
504+
onStateUpdate: wrappedOnStateUpdate,
505+
};
506+
436507
return m(
437508
'.pf-explore-page',
438509
{
439-
onkeydown: (e: KeyboardEvent) => this.handleKeyDown(e, attrs),
510+
onkeydown: (e: KeyboardEvent) => this.handleKeyDown(e, wrappedAttrs),
440511
oncreate: (vnode) => {
441512
(vnode.dom as HTMLElement).focus();
442513
},
@@ -449,20 +520,21 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
449520
selectedNode: state.selectedNode,
450521
nodeLayouts: state.nodeLayouts,
451522
devMode: state.devMode,
452-
onDevModeChange: (enabled) => this.handleDevModeChange(attrs, enabled),
523+
onDevModeChange: (enabled) =>
524+
this.handleDevModeChange(wrappedAttrs, enabled),
453525
onRootNodeCreated: (node) => {
454-
attrs.onStateUpdate((currentState) => ({
526+
wrappedAttrs.onStateUpdate((currentState) => ({
455527
...currentState,
456528
rootNodes: [...currentState.rootNodes, node],
457529
selectedNode: node,
458530
}));
459531
},
460532
onNodeSelected: (node) => {
461-
if (node) this.selectNode(attrs, node);
533+
if (node) this.selectNode(wrappedAttrs, node);
462534
},
463-
onDeselect: () => this.deselectNode(attrs),
535+
onDeselect: () => this.deselectNode(wrappedAttrs),
464536
onNodeLayoutChange: (nodeId, layout) => {
465-
attrs.onStateUpdate((currentState) => {
537+
wrappedAttrs.onStateUpdate((currentState) => {
466538
const newNodeLayouts = new Map(currentState.nodeLayouts);
467539
newNodeLayouts.set(nodeId, layout);
468540
return {
@@ -472,27 +544,28 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
472544
});
473545
},
474546
onAddSourceNode: (id) => {
475-
this.handleAddSourceNode(attrs, id);
547+
this.handleAddSourceNode(wrappedAttrs, id);
476548
},
477549
onAddOperationNode: (id, node) => {
478-
this.handleAddOperationNode(attrs, node, id);
550+
this.handleAddOperationNode(wrappedAttrs, node, id);
479551
},
480-
onClearAllNodes: () => this.handleClearAllNodes(attrs),
552+
onClearAllNodes: () => this.handleClearAllNodes(wrappedAttrs),
481553
onDuplicateNode: () => {
482554
if (state.selectedNode) {
483-
this.handleDuplicateNode(attrs, state.selectedNode);
555+
this.handleDuplicateNode(wrappedAttrs, state.selectedNode);
484556
}
485557
},
486558
onDeleteNode: () => {
487559
if (state.selectedNode) {
488-
this.handleDeleteNode(attrs, state.selectedNode);
560+
this.handleDeleteNode(wrappedAttrs, state.selectedNode);
489561
}
490562
},
491563
onConnectionRemove: (fromNode, toNode) => {
492-
this.handleConnectionRemove(attrs, fromNode, toNode);
564+
this.handleConnectionRemove(wrappedAttrs, fromNode, toNode);
493565
},
494-
onImport: () => this.handleImport(attrs),
495-
onImportWithStatement: () => this.handleImportWithStatement(attrs),
566+
onImport: () => this.handleImport(wrappedAttrs),
567+
onImportWithStatement: () =>
568+
this.handleImportWithStatement(wrappedAttrs),
496569
onExport: () => this.handleExport(state, trace),
497570
onRemoveFilter: (node, filter) => {
498571
if (node.state.filters) {
@@ -501,8 +574,19 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
501574
node.state.filters.splice(filterIndex, 1);
502575
}
503576
}
504-
attrs.onStateUpdate((currentState) => ({...currentState}));
577+
wrappedAttrs.onStateUpdate((currentState) => ({...currentState}));
578+
},
579+
onNodeStateChange: () => {
580+
// Trigger a state update when node properties change (e.g., selecting group by columns)
581+
// This ensures these granular changes are captured in history
582+
wrappedAttrs.onStateUpdate((currentState) => {
583+
return {...currentState};
584+
});
505585
},
586+
onUndo: () => this.handleUndo(attrs),
587+
onRedo: () => this.handleRedo(attrs),
588+
canUndo: this.historyManager?.canUndo() ?? false,
589+
canRedo: this.historyManager?.canRedo() ?? false,
506590
}),
507591
);
508592
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (C) 2025 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import {ExplorePageState} from './explore_page';
16+
import {serializeState, deserializeState} from './json_handler';
17+
import {Trace} from '../../public/trace';
18+
import {SqlModules} from '../dev.perfetto.SqlModules/sql_modules';
19+
20+
const MAX_HISTORY_SIZE = 10;
21+
22+
export class HistoryManager {
23+
private history: string[] = [];
24+
private currentIndex: number = -1;
25+
private isUndoRedoInProgress: boolean = false;
26+
private lastSerializedState?: string;
27+
28+
constructor(
29+
private trace: Trace,
30+
private sqlModules: SqlModules,
31+
) {}
32+
33+
// Serialize only the meaningful parts (exclude nodeLayouts and selectedNode)
34+
private serializeForComparison(state: ExplorePageState): string {
35+
const stateWithoutLayoutAndSelection = {
36+
...state,
37+
nodeLayouts: new Map(), // Exclude layout from comparison
38+
selectedNode: undefined, // Exclude selected node from comparison
39+
};
40+
return serializeState(stateWithoutLayoutAndSelection);
41+
}
42+
43+
// Push a new state to history
44+
pushState(state: ExplorePageState): void {
45+
// Don't record history changes triggered by undo/redo
46+
if (this.isUndoRedoInProgress) {
47+
return;
48+
}
49+
50+
const serialized = serializeState(state);
51+
const serializedForComparison = this.serializeForComparison(state);
52+
53+
// Skip if the meaningful state (without layout) hasn't changed
54+
// This filters out layout-only changes while capturing all other changes
55+
if (
56+
this.lastSerializedState &&
57+
serializedForComparison === this.lastSerializedState
58+
) {
59+
return;
60+
}
61+
62+
this.lastSerializedState = serializedForComparison;
63+
64+
// If we're not at the end of history, remove everything after current index
65+
if (this.currentIndex < this.history.length - 1) {
66+
this.history = this.history.slice(0, this.currentIndex + 1);
67+
}
68+
69+
// Add the new state
70+
this.history.push(serialized);
71+
72+
// Keep only the last MAX_HISTORY_SIZE states
73+
if (this.history.length > MAX_HISTORY_SIZE) {
74+
this.history.shift();
75+
} else {
76+
this.currentIndex++;
77+
}
78+
}
79+
80+
// Undo to previous state
81+
undo(): ExplorePageState | null {
82+
if (!this.canUndo()) {
83+
return null;
84+
}
85+
86+
this.currentIndex--;
87+
this.isUndoRedoInProgress = true;
88+
const state = deserializeState(
89+
this.history[this.currentIndex],
90+
this.trace,
91+
this.sqlModules,
92+
);
93+
this.isUndoRedoInProgress = false;
94+
return state;
95+
}
96+
97+
// Redo to next state
98+
redo(): ExplorePageState | null {
99+
if (!this.canRedo()) {
100+
return null;
101+
}
102+
103+
this.currentIndex++;
104+
this.isUndoRedoInProgress = true;
105+
const state = deserializeState(
106+
this.history[this.currentIndex],
107+
this.trace,
108+
this.sqlModules,
109+
);
110+
this.isUndoRedoInProgress = false;
111+
return state;
112+
}
113+
114+
canUndo(): boolean {
115+
return this.currentIndex > 0;
116+
}
117+
118+
canRedo(): boolean {
119+
return this.currentIndex < this.history.length - 1;
120+
}
121+
122+
clear(): void {
123+
this.history = [];
124+
this.currentIndex = -1;
125+
}
126+
}

0 commit comments

Comments
 (0)