--- src/core/org/apache/jmeter/gui/GuiPackage.java (revision 1617267) +++ src/core/org/apache/jmeter/gui/GuiPackage.java (working copy) @@ -44,6 +44,9 @@ import org.apache.jmeter.testbeans.gui.TestBeanGUI; import org.apache.jmeter.testelement.TestElement; import org.apache.jmeter.testelement.TestPlan; +import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.testelement.property.PropertyIterator; +import org.apache.jmeter.testelement.property.TestElementProperty; import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.LocaleChangeEvent; import org.apache.jmeter.util.LocaleChangeListener; @@ -104,7 +107,7 @@ /** The main JMeter frame. */ private MainFrame mainFrame; - + /** The main JMeter toolbar. */ private JToolBar toolbar; @@ -121,13 +124,18 @@ */ private LoggerPanel loggerPanel; - /** + * History for tree states + */ + private UndoHistory undoHistory = new UndoHistory(); + + /** * Private constructor to permit instantiation only from within this class. * Use {@link #getInstance()} to retrieve a singleton instance. */ private GuiPackage(JMeterTreeModel treeModel, JMeterTreeListener treeListener) { this.treeModel = treeModel; + this.treeModel.addTreeModelListener(undoHistory); this.treeListener = treeListener; JMeterUtils.addLocaleChangeListener(this); } @@ -155,6 +163,7 @@ public static GuiPackage getInstance(JMeterTreeListener listener, JMeterTreeModel treeModel) { if (guiPack == null) { guiPack = new GuiPackage(treeModel, listener); + guiPack.undoHistory.add(treeModel, "Created"); } return guiPack; } @@ -408,8 +417,12 @@ log.debug("Updating current node " + currentNode.getName()); JMeterGUIComponent comp = getGui(currentNode.getTestElement()); TestElement el = currentNode.getTestElement(); + int before = getTestElementCheckSum(el); comp.modifyTestElement(el); - currentNode.nameChanged(); // Bug 50221 - ensure label is updated + int after = getTestElementCheckSum(el); + if (before != after) { + currentNode.nameChanged(); // Bug 50221 - ensure label is updated + } } // The current node is now updated currentNodeUpdated = true; @@ -464,7 +477,10 @@ * if a subtree cannot be added to the currently selected node */ public HashTree addSubTree(HashTree subTree) throws IllegalUserActionException { - return treeModel.addSubTree(subTree, treeListener.getCurrentNode()); + HashTree hashTree = treeModel.addSubTree(subTree, treeListener.getCurrentNode()); + undoHistory.clear(); + undoHistory.add(this.treeModel, "Loaded tree"); + return hashTree; } /** @@ -527,7 +543,7 @@ public JMeterTreeListener getTreeListener() { return treeListener; } - + /** * Set the main JMeter toolbar. * @@ -546,7 +562,7 @@ public JToolBar getMainToolbar() { return toolbar; } - + /** * Set the menu item toolbar. * @@ -670,6 +686,8 @@ getTreeModel().clearTestPlan(); nodesToGui.clear(); setTestPlanFile(null); + undoHistory.clear(); + undoHistory.add(this.treeModel, "Initial Tree"); } /** @@ -680,6 +698,8 @@ public void clearTestPlan(TestElement element) { getTreeModel().clearTestPlan(element); removeNode(element); + undoHistory.clear(); + undoHistory.add(this.treeModel, "Initial Tree"); } public static void showErrorMessage(final String message, final String title){ @@ -720,7 +740,7 @@ } } } - + /** * Register process to stop on reload * @param stoppable @@ -730,7 +750,7 @@ } /** - * + * * @return List Copy of IStoppable */ public List getStoppables() { @@ -746,7 +766,7 @@ public void setMenuItemLoggerPanel(JCheckBoxMenuItem menuItemLoggerPanel) { this.menuItemLoggerPanel = menuItemLoggerPanel; } - + /** * Get the menu item LoggerPanel. * @@ -769,4 +789,53 @@ public LoggerPanel getLoggerPanel() { return loggerPanel; } + + /** + * Navigate up and down in history + * + * @param offset int + */ + public void goInHistory(int offset) { + undoHistory.getRelativeState(offset, this.treeModel); + } + + /** + * @return true if history contains redo item + */ + public boolean canRedo() { + return undoHistory.canRedo(); + } + + /** + * @return true if history contains undo item + */ + public boolean canUndo() { + return undoHistory.canUndo(); + } + + /** + * Compute checksum of TestElement to detect changes + * the method calculates properties checksum to detect testelement + * modifications + * TODO would be better to override hashCode for TestElement, but I decided to touch it + * + * @param el {@link TestElement} + * @return int checksum + */ + private int getTestElementCheckSum(TestElement el) { + int ret = el.getClass().hashCode(); + PropertyIterator it = el.propertyIterator(); + while (it.hasNext()) { + JMeterProperty obj = it.next(); + if (obj instanceof TestElementProperty) { + ret ^= getTestElementCheckSum(((TestElementProperty) obj) + .getElement()); + } else { + ret ^= obj.getName().hashCode(); + ret ^= obj.getStringValue().hashCode(); + } + } + return ret; + } + } --- src/core/org/apache/jmeter/gui/UndoHistory.java (revision 0) +++ src/core/org/apache/jmeter/gui/UndoHistory.java (working copy) @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.jmeter.gui; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.*; +import javax.swing.event.TreeModelEvent; +import javax.swing.event.TreeModelListener; + +import org.apache.jmeter.gui.action.UndoCommand; +import org.apache.jmeter.gui.tree.JMeterTreeModel; +import org.apache.jmeter.gui.tree.JMeterTreeNode; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.logging.LoggingManager; +import org.apache.log.Logger; + +/** + * Users expected record situations: initial empty tree; before node deletion; + * before node insertion; after each walk off edited node (modifyTestElement) + */ +public class UndoHistory implements TreeModelListener, Serializable { + private ArrayList savedExpanded = new ArrayList(); + private int savedSelected = 0; + + /** + * Avoid storing too many elements + * + * @param + */ + private static class LimitedArrayList extends ArrayList { + /** + * + */ + private static final long serialVersionUID = -6574380490156356507L; + private int limit; + + public LimitedArrayList(int limit) { + this.limit = limit; + } + + @Override + public boolean add(T item) { + if (this.size() + 1 > limit) { + this.remove(0); + } + return super.add(item); + } + } + + private static final int INITIAL_POS = -1; + private static final Logger log = LoggingManager.getLoggerForClass(); + + private List history = new LimitedArrayList(25); // TODO Make this configurable or too many properties ? + private int position = INITIAL_POS; + + /** + * flag to prevent recursive actions + */ + private boolean working = false; + + public UndoHistory() { + } + + /** + * @return true if must not put in history + */ + private boolean noop() { + return working; + } + + /** + * + */ + public void clear() { + if (noop()) { + return; + } + log.debug("Clearing undo history", new Throwable()); + history.clear(); + position = INITIAL_POS; + } + + /** + * this method relies on the rule that the record in history made AFTER + * change has been made to test plan + * + * @param treeModel JMeterTreeModel + * @param comment String + */ + public void add(JMeterTreeModel treeModel, String comment) { + // don't add element if we are in the middle of undo/redo or a big loading + if (noop()) { + log.debug("Not adding history because of noop"); + return; + } + JMeterTreeNode root = (JMeterTreeNode) treeModel.getRoot(); + if (root.getChildCount() < 1) { + log.debug("Not adding history because of no children", new Throwable()); + return; + } + + String name = ((JMeterTreeNode) treeModel.getRoot()).getName(); + + if (log.isDebugEnabled()) { + log.debug("Adding history element " + name + ": " + comment, new Throwable()); + } + + working = true; + // get test plan tree + HashTree tree = treeModel.getCurrentSubTree((JMeterTreeNode) treeModel.getRoot()); + // first clone to not convert original tree + tree = (HashTree) tree.getTree(tree.getArray()[0]).clone(); + + position++; + while (history.size() > position) { + log.debug("Removing further record, position: " + position + ", size: " + history.size()); + history.remove(history.size() - 1); + } + + // cloning is required because we need to immute stored data + HashTree copy = UndoCommand.convertSubTree(tree); + + history.add(new UndoHistoryItem(copy, comment)); + + log.debug("Added history element, position: " + position + ", size: " + history.size()); + working = false; + } + + public void getRelativeState(int offset, JMeterTreeModel acceptorModel) { + log.debug("Moving history from position " + position + " with step " + offset + ", size is " + history.size()); + if (offset < 0 && !canUndo()) { + log.warn("Can't undo, we're already on the last record"); + return; + } + + if (offset > 0 && !canRedo()) { + log.warn("Can't redo, we're already on the first record"); + return; + } + + if (history.isEmpty()) { + log.warn("Can't proceed, the history is empty"); + return; + } + + position += offset; + + final GuiPackage guiInstance = GuiPackage.getInstance(); + + saveTreeState(guiInstance); + + loadHistoricalTree(acceptorModel, guiInstance); + + log.debug("Current position " + position + ", size is " + history.size()); + + restoreTreeState(guiInstance); + + guiInstance.updateCurrentGui(); + guiInstance.getMainFrame().repaint(); + } + + private void loadHistoricalTree(JMeterTreeModel acceptorModel, GuiPackage guiInstance) { + HashTree newModel = history.get(position).getTree(); + acceptorModel.removeTreeModelListener(this); + working = true; + try { + guiInstance.getTreeModel().clearTestPlan(); + guiInstance.addSubTree(newModel); + } catch (Exception ex) { + log.error("Failed to load from history", ex); + } + acceptorModel.addTreeModelListener(this); + working = false; + } + + /** + * @return true if remaing items + */ + public boolean canRedo() { + return position < history.size() - 1; + } + + /** + * @return true if not at first element + */ + public boolean canUndo() { + return position > INITIAL_POS + 1; + } + + public void treeNodesChanged(TreeModelEvent tme) { + String name = ((JMeterTreeNode) tme.getTreePath().getLastPathComponent()).getName(); + log.debug("Nodes changed " + name); + final JMeterTreeModel sender = (JMeterTreeModel) tme.getSource(); + add(sender, "Node changed " + name); + } + + /** + * + */ + // FIXME: is there better way to record test plan load events? currently it records each node added separately + public void treeNodesInserted(TreeModelEvent tme) { + String name = ((JMeterTreeNode) tme.getTreePath().getLastPathComponent()).getName(); + log.debug("Nodes inserted " + name); + final JMeterTreeModel sender = (JMeterTreeModel) tme.getSource(); + add(sender, "Add " + name); + } + + /** + * + */ + public void treeNodesRemoved(TreeModelEvent tme) { + String name = ((JMeterTreeNode) tme.getTreePath().getLastPathComponent()).getName(); + log.debug("Nodes removed: " + name); + add((JMeterTreeModel) tme.getSource(), "Remove " + name); + } + + /** + * + */ + public void treeStructureChanged(TreeModelEvent tme) { + log.debug("Nodes struct changed"); + add((JMeterTreeModel) tme.getSource(), "Complex Change"); + } + + /** + * @param guiPackage + * @return int[] + */ + private void saveTreeState(GuiPackage guiPackage) { + savedExpanded.clear(); + + MainFrame mainframe = guiPackage.getMainFrame(); + if (mainframe != null) { + final JTree tree = mainframe.getTree(); + savedSelected = tree.getMinSelectionRow(); + + for (int rowN = 0; rowN < tree.getRowCount(); rowN++) { + if (tree.isExpanded(rowN)) { + savedExpanded.add(rowN); + } + } + } + } + + private void restoreTreeState(GuiPackage guiInstance) { + final JTree tree = guiInstance.getMainFrame().getTree(); + + if (savedExpanded.size() > 0) { + for (int rowN : savedExpanded) { + tree.expandRow(rowN); + } + } else { + tree.expandRow(0); + } + tree.setSelectionRow(savedSelected); + } + +} --- src/core/org/apache/jmeter/gui/UndoHistoryItem.java (revision 0) +++ src/core/org/apache/jmeter/gui/UndoHistoryItem.java (working copy) @@ -0,0 +1,38 @@ +package org.apache.jmeter.gui; + +import org.apache.jorphan.collections.HashTree; + +import java.io.Serializable; + +/** + * Undo history item + */ +public class UndoHistoryItem implements Serializable { + + private final HashTree tree; + // TODO: find a way to show this comment in menu item and toolbar tooltip + private final String comment; + + /** + * @param copy HashTree + * @param acomment String + */ + public UndoHistoryItem(HashTree copy, String acomment) { + tree = copy; + comment = acomment; + } + + /** + * @return {@link org.apache.jorphan.collections.HashTree} + */ + public HashTree getTree() { + return tree; + } + + /** + * @return String comment + */ + public String getComment() { + return comment; + } +} --- src/core/org/apache/jmeter/gui/action/ActionNames.java (revision 1617267) +++ src/core/org/apache/jmeter/gui/action/ActionNames.java (working copy) @@ -95,6 +95,8 @@ public static final String MOVE_DOWN = "move_down"; // $NON-NLS-1$ public static final String MOVE_LEFT = "move_left"; // $NON-NLS-1$ public static final String MOVE_RIGHT = "move_right"; // $NON-NLS-1$ + public static final String UNDO = "undo"; // $NON-NLS-1$ + public static final String REDO = "redo"; // $NON-NLS-1$ // Prevent instantiation private ActionNames(){ --- src/core/org/apache/jmeter/gui/action/UndoCommand.java (revision 0) +++ src/core/org/apache/jmeter/gui/action/UndoCommand.java (working copy) @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.jmeter.gui.action; + +import java.awt.event.ActionEvent; +import java.util.HashSet; +import java.util.Set; + +import org.apache.jmeter.engine.TreeCloner; +import org.apache.jmeter.exceptions.IllegalUserActionException; +import org.apache.jmeter.gui.GuiPackage; +import org.apache.jorphan.collections.HashTree; + +/** + * + */ +public class UndoCommand implements Command { + + private static final Set commands = new HashSet(); + + static { + commands.add(ActionNames.UNDO); + commands.add(ActionNames.REDO); + } + + public void doAction(ActionEvent e) throws IllegalUserActionException { + GuiPackage guiPackage = GuiPackage.getInstance(); + final String command = e.getActionCommand(); + + if (command.equals(ActionNames.UNDO)) { + guiPackage.goInHistory(-1); + } else if (command.equals(ActionNames.REDO)) { + guiPackage.goInHistory(1); + } else { + throw new IllegalArgumentException("Wrong action called: " + command); + } + } + + /** + * @return Set + */ + public Set getActionNames() { + return commands; + } + + /** + * wrapper to use package-visible method + * and clone tree for saving + * + * @param tree to be converted and cloned + */ + public static HashTree convertSubTree(HashTree tree) { + Save executor = new Save(); + executor.convertSubTree(tree); + + // convert before clone + TreeCloner cloner = new TreeCloner(false); + tree.traverse(cloner); + return cloner.getClonedTree(); + } +} --- src/core/org/apache/jmeter/gui/util/MenuFactory.java (revision 1617267) +++ src/core/org/apache/jmeter/gui/util/MenuFactory.java (working copy) @@ -206,6 +206,10 @@ * @param addSaveTestFragmentMenu Add Save as Test Fragment menu if true */ public static void addFileMenu(JPopupMenu menu, boolean addSaveTestFragmentMenu) { + // the undo/redo as a standard goes first in Edit menus + // maybe there's better place for them in JMeter? + addUndoItems(menu); + addSeparator(menu); menu.add(makeMenuItemRes("open", ActionNames.OPEN));// $NON-NLS-1$ menu.add(makeMenuItemRes("menu_merge", ActionNames.MERGE));// $NON-NLS-1$ @@ -247,6 +251,27 @@ menu.add(makeMenuItemRes("help", ActionNames.HELP));// $NON-NLS-1$ } + /** + * Add undo / redo + * @param menu JPopupMenu + */ + private static void addUndoItems(JPopupMenu menu) { + addSeparator(menu); + + JMenuItem undo = makeMenuItemRes("undo", ActionNames.UNDO); //$NON-NLS-1$ + //undo.setAccelerator(KeyStrokes.UNDO); + undo.setEnabled(GuiPackage.getInstance().canUndo()); + menu.add(undo); + + JMenuItem redo = makeMenuItemRes("redo", ActionNames.REDO); //$NON-NLS-1$ + //redo.setAccelerator(KeyStrokes.REDO); + // TODO: we could even show some hints on action being undone here if this will be required (by passing those hints into history records) + redo.setEnabled(GuiPackage.getInstance().canRedo()); + menu.add(redo); + // TODO: find a way to enable/disable toolbar items depending on action states + } + + public static JMenu makeMenus(String[] categories, String label, String actionCommand) { JMenu addMenu = new JMenu(label); for (int i = 0; i < categories.length; i++) { --- src/core/org/apache/jmeter/images/toolbar/icons-toolbar.properties (revision 1617267) +++ src/core/org/apache/jmeter/images/toolbar/icons-toolbar.properties (working copy) @@ -14,7 +14,7 @@ # limitations under the License. # Icons order. Keys separate by comma. Use a pipe | to have a space between two icons. -toolbar=new,templates,open,close,save,save_as_testplan,|,cut,copy,paste,|,expand,collapse,toggle,|,test_start,test_start_notimers,test_stop,test_shutdown,|,test_start_remote_all,test_stop_remote_all,test_shutdown_remote_all,|,test_clear,test_clear_all,|,search,search_reset,|,function_helper,help +toolbar=new,templates,open,close,save,save_as_testplan,|,undo,redo,cut,copy,paste,|,expand,collapse,toggle,|,test_start,test_start_notimers,test_stop,test_shutdown,|,test_start_remote_all,test_stop_remote_all,test_shutdown_remote_all,|,test_clear,test_clear_all,|,search,search_reset,|,function_helper,help # Icon / action definition file. # Key: button names @@ -43,4 +43,6 @@ search=menu_search,SEARCH_TREE,org/apache/jmeter/images/toolbar/search.png search_reset=menu_search_reset,SEARCH_RESET,org/apache/jmeter/images/toolbar/searchreset.png function_helper=function_dialog_menu_item,FUNCTIONS,org/apache/jmeter/images/toolbar/function.png -help=help,HELP,org/apache/jmeter/images/toolbar/help.png +help=help,HELP,org/apache/jmeter/images/toolbar/help.png +undo=undo,UNDO,org/apache/jmeter/images/toolbar/undo.png +redo=redo,REDO,org/apache/jmeter/images/toolbar/redo.png --- src/core/org/apache/jmeter/resources/messages.properties (revision 1617267) +++ src/core/org/apache/jmeter/resources/messages.properties (working copy) @@ -769,6 +769,7 @@ read_soap_response=Read SOAP Response realm=Realm record_controller_title=Recording Controller +redo=Redo ref_name_field=Reference Name\: regex_extractor_title=Regular Expression Extractor regex_field=Regular Expression\: @@ -1131,6 +1132,7 @@ transaction_controller_parent=Generate parent sample transaction_controller_title=Transaction Controller unbind=Thread Unbind +undo=Undo unescape_html_string=String to unescape unescape_string=String containing Java escapes uniform_timer_delay=Constant Delay Offset (in milliseconds)\: --- src/core/org/apache/jmeter/resources/messages_fr.properties (revision 1617267) +++ src/core/org/apache/jmeter/resources/messages_fr.properties (working copy) @@ -762,6 +762,7 @@ read_soap_response=Lire la r\u00E9ponse SOAP realm=Univers (realm) record_controller_title=Contr\u00F4leur Enregistreur +redo=R\u00E9tablir ref_name_field=Nom de r\u00E9f\u00E9rence \: regex_extractor_title=Extracteur Expression r\u00E9guli\u00E8re regex_field=Expression r\u00E9guli\u00E8re \: @@ -1124,6 +1125,7 @@ transaction_controller_parent=G\u00E9n\u00E9rer en \u00E9chantillon parent transaction_controller_title=Contr\u00F4leur Transaction unbind=D\u00E9connexion de l'unit\u00E9 +undo=Annuler unescape_html_string=Cha\u00EEne \u00E0 \u00E9chapper unescape_string=Cha\u00EEne de caract\u00E8res contenant des\u00E9chappements Java uniform_timer_delay=D\u00E9lai de d\u00E9calage constant (en millisecondes) \: