Undo/Redo Support

Author: Nathan Fiedler

A Brief History

In order to fix CR 6382275 (in which the user makes a change in one view, invokes undo in another view, then invokes redo in the first view, and subsequently the document becomes corrupted), the undo/redo support in the schema editor was re-designed. After attempting a solution in which only one undo manager was used, we determined that we need to use two undo managers, one for the Source view, the other for the Schema view. However, this also led to problems (issues 77333, 80586, 80754), and a further refactoring of the undo/redo support was needed.

This specification outlines how the undo/redo feature behaves, and gives some details on the implementation.

User View

Undo/Redo in Schema View

Each change made to the model in the Schema view is saved to the undo queue of the view as an undoable edit. For instance, if a new complex type is added, pressing the Undo button in the toolbar will make the complex type disappear. Pressing the Redo button will cause it to appear again. If a new change is made after undoing a previous change, that previous change is lost forever. This is identical to how undo/redo behaves in the NetBeans source editor.

The undo/redo queue is limited to 1000 edits, in that only the last 1000 edits are undoable. An edit is the addition or removal of a schema component, as well as changes to property values. This is the standard limit defined by the NetBeans editor.

Switching to the Source view, all edits made in the Schema view can be undone/redone from the Source view.

Undo/Redo in Source View

While making edits in the Source view, the undo/redo will behave exactly as it does in the standard NetBeans source editor.

The undo/redo queue is limited to 1000 edits, in that only the last 1000 edits are undoable. An edit constitutes either an insertion or deletion of text, and may represent one or more characters. This is the standard limit defined by the NetBeans editor.

When switching away from the Source view, the undoable edits made to the source will be undone and redone as a set. This in effect treats those edits as a single change. If this were not done, the undo/redo would need to be invoked many times to undo all of the individual edits made in the source. Since those actions are not likely to be visible in the model view, it would probably be confusing to the user.

Testing Scenarios

The following may be useful for the purpose of testing the undo/redo behavior of the schema editor.

Case A
Initially the actions should be disabled.
  • Is undo/redo disabled when source view is initially shown?
  • Is undo/redo disabled when model view is initially shown?
Case B
Undo/redo of a model edit.
  • In model view, add or delete a component.
  • Is undo enabled?
  • Does undo/redo work as expected?
  • Does undo/redo work from the source view?
Case C
Undo/redo of a source edit.
  • In source view, add a new element (e.g. copy and paste an element, then change the element name).
  • Is undo enabled?
  • Does undo/redo work as expected?
  • Does undo/redo work from the model view?
  • Does undo/redo still work from the source view?
Case D
Undo should clear the modified status.
  • In model view, add a new element.
  • Undo should clear modified status.
  • Redo should set modified status.
  • Save — modified status clear.
  • Add another element — modified status set.
  • Undo — modified status clear.
  • Redo — modified status set.
  • Undo, undo — modified status set.
  • Redo — modified status cleared.
  • Redo — modified status set.
  • Delete the new elements and save.
Case E
Cloned views and undo/redo.
  • Open a document, then clone the view and put the two editors side-by-side in the IDE.
  • Edit the source, observe changes in both editors.
  • Switch one editor to the model view.
  • Undo/redo in model view should add/remove text in source view.
  • Add/delete a component in model view.
  • Undo/redo should add/remove the text in source view.
Case F
Undo/redo with a mix of document and model edits.
  • In source view, add a new element (copy, paste, rename).
  • In model view, add/delete a component.
  • In source view, invoke undo — should first undo the model edit, and further undo invocations will undo the source edits.
Case G
No exceptions when deleting un-opened file.
  • Open a project.
  • Create a blank .xsd file.
  • Close the editor for the new file.
  • Restart the IDE.
  • Delete the blank .xsd file — no exceptions should occur.

Corner Cases

The following are corner cases that verify the correct behavior of the undo manager in unusual circumstances.

Case AA
Single document edit.
  • Open a .xsd file (i.e. start with a clean document).
  • In source view, type a single character.
  • In model view, undo/redo should work.
Case AC
An open compound edit is properly closed.
  • In source view, add two spaces, at different positions.
  • Switch to model view and back again.
  • Undo once.
  • Add another space somewhere else.
  • In model view, undo once will undo all text edits.
  • In source view, undo twice will undo all text edits.
Case AD
Switching views leaves undo history intact.
  • In source view, add two spaces, at different positions.
  • Undo once.
  • Switch to model view and back again.
  • Undo to remove the one space.
  • Redo twice to add the two spaces.
Case AE
Open compound edit and switching views should close compound set.
  • In source view, add two spaces, at different positions.
  • Switch to model view and back again.
  • Undo once.
  • In model view, undo should be once, redo also just once (undoing and redoing both spaces).
Case AF
Best tested with cloned editors, one in source view, one in model view.
  • In source view, add two spaces, at different positions.
  • Undo once.
  • In model view, add a new component.
  • Undo — undoes component add.
  • Undo works once more to undo the one space.
  • Redo brings the one space back.
  • Redo works once more to redo the component add.

Using the Undo/Redo Support

The basic undo/redo support is available from the xamui module of the epsuite in the xml source directory (currently in the release55 branch of the NetBeans CVS repository). The classes of interest are defined in the org.netbeans.modules.xml.xam.ui.undo package. Of the classes in that package, the interesting one is the QuietUndoManager, which performs all of the necessary steps as outlined in the strategy section above.

Using the QuietUndoManager is quite simple, as shown by the code examples below. Note that because the editors may be cloned, it is necessary to add and remove the undo manager in both the componentShowing/componentHidden methods, and the componentActivated/componentDeactivated methods. In so doing, it is necessary to first remove the listener, then add it to avoid registering the same listener twice.

In the CloneableEditorSupport subclass... (sample taken from schema/core module)

    @Override
    protected UndoRedo.Manager createUndoRedoManager() {
        // Override so the superclass will use our proxy undo manager
        // instead of the default, then we can intercept edits.
        return new QuietUndoManager(super.createUndoRedoManager());
        // Note we cannot set the document on the undo manager right
        // now, as CES is probably trying to open the document.
    }

    /**
     * Returns the UndoRedo.Manager instance managed by this editor support.
     *
     * @return specialized UndoRedo.Manager instance.
     */
    public QuietUndoManager getUndoManager() {
        return (QuietUndoManager) getUndoRedo();
    }

    /**
     * Adds the undo/redo manager to the document as an undoable edit
     * listener, so it receives the edits onto the queue. The manager
     * will be removed from the model as an undoable edit listener.
     *
     * This method may be called repeatedly.
     */
    public void addUndoManagerToDocument() {
        // This method may be called repeatedly.
        // Stop the undo manager from listening to the model, as it will
        // be listening to the document now.
        QuietUndoManager undo = getUndoManager();
        StyledDocument doc = getDocument();
        synchronized (undo) {
            try {
                SchemaModel model = getModel();
                if (model != null) {
                    model.removeUndoableEditListener(undo);
                }
                // Must unset the model when no longer listening to it.
                undo.setModel(null);
            } catch (IOException ioe) {
                // Model is gone, but just removing the listener is not
                // going to matter anyway.
            }
            // Document may be null if the cloned views are not behaving correctly.
            if (doc != null) {
                // Ensure the listener is not added twice.
                doc.removeUndoableEditListener(undo);
                doc.addUndoableEditListener(undo);
                // Start the compound mode of the undo manager, such that when
                // we are hidden, we will treat all of the edits as a single
                // compound edit. This avoids having the user invoke undo
                // numerous times when in the model view.
                undo.beginCompound();
            }
        }
    }

    /**
     * Removes the undo/redo manager undoable edit listener from the
     * document, to stop receiving undoable edits. The manager will
     * be added to the model as an undoable edit listener.
     *
     * This method may be called repeatedly.
     */
    public void removeUndoManagerFromDocument() {
        // This method may be called repeatedly.
        QuietUndoManager undo = getUndoManager();
        StyledDocument doc = getDocument();
        synchronized (undo) {
            // May be null when closing the editor.
            if (doc != null) {
                doc.removeUndoableEditListener(undo);
                undo.endCompound();
            }
            // Have the undo manager listen to the model when it is not
            // listening to the document.
            addUndoManagerToModel(undo);
        }
    }

    /**
     * Add the undo/redo manager undoable edit listener to the model.
     *
     * Caller should synchronize on the undo manager prior to calling
     * this method, to avoid thread concurrency issues.
     *
     * @param  undo  the undo manager.
     */
    private void addUndoManagerToModel(QuietUndoManager undo) {
        // This method may be called repeatedly.
        try {
            SchemaModel model = getModel();
            if (model != null) {
                // Ensure the listener is not added twice.
                model.removeUndoableEditListener(undo);
                model.addUndoableEditListener(undo);
                // Ensure the model is sync'd when undo/redo is invoked,
                // otherwise the edits are added to the queue and eventually
                // cause exceptions.
                undo.setModel(model);
            }
        } catch (IOException ioe) {
            // Model is gone, nothing will work, return immediately.
        }
    }

    public Task prepareDocument() {
        Task task = super.prepareDocument();
        // Avoid listening to the same task more than once.
        if (task != prepareTask2) {
            prepareTask2 = task;
            task.addTaskListener(new TaskListener() {
                public void taskFinished(Task task) {
                    QuietUndoManager undo = getUndoManager();
                    StyledDocument doc = getDocument();
                    synchronized (undo) {
                        // Now that the document is ready, pass it to the manager.
                        undo.setDocument((AbstractDocument) doc);
                        if (!undo.isCompound()) {
                            // The superclass prepareDocument() adds the undo/redo
                            // manager as a listener -- we need to remove it since
                            // we will initially listen to the model instead.
                            doc.removeUndoableEditListener(undo);
                            // If not listening to document, then listen to model.
                            addUndoManagerToModel(undo);
                        }
                    }
                }
            });
        }
        return task;
    }

    public Task reloadDocument() {
        Task task = super.reloadDocument();
        task.addTaskListener(new TaskListener() {
            public void taskFinished(Task task) {
                EventQueue.invokeLater(new Runnable() {
                    public void run() {
                        QuietUndoManager undo = getUndoManager();
                        StyledDocument doc = getDocument();
                        // The superclass reloadDocument() adds the undo
                        // manager as an undoable edit listener.
                        synchronized (undo) {
                            if (!undo.isCompound()) {
                                doc.removeUndoableEditListener(undo);
                            }
                        }
                    }
                });
            }
        });
        return task;
    }

    protected void notifyClosed() {
        // Stop listening to the undoable edit sources when we are closed.
        QuietUndoManager undo = getUndoManager();
        StyledDocument doc = getDocument();
        synchronized (undo) {
            // May be null when closing the editor.
            if (doc != null) {
                doc.removeUndoableEditListener(undo);
                undo.endCompound();
            }
            try {
                SchemaModel model = getModel();
                if (model != null) {
                    model.removeUndoableEditListener(undo);
                }
                // Must unset the model when no longer listening to it.
                undo.setModel(null);
            } catch (IOException ioe) {
                // Model is gone, but just removing the listener is not
                // going to matter anyway.
            }
        }

        super.notifyClosed();
    }

In the CloneableEditor subclass...

    public void componentActivated() {
        super.componentActivated();
        MyEditorSupport editor = dataObject.getEditorSupport();
        editor.addUndoManagerToDocument();
    }

    public void componentDeactivated() {
        super.componentDeactivated();
        MyEditorSupport editor = dataObject.getEditorSupport();
        // Sync model before having undo manager listen to the model,
        // lest we get redundant undoable edits added to the queue.
        editor.syncModel();
        editor.removeUndoManagerFromDocument();
    }

    public void componentShowing() {
        super.componentShowing();
        MyEditorSupport editor = dataObject.getEditorSupport();
        editor.addUndoManagerToDocument();
    }

    public void componentHidden() {
        super.componentHidden();
        MyEditorSupport editor = dataObject.getEditorSupport();
        // Sync model before having undo manager listen to the model,
        // lest we get redundant undoable edits added to the queue.
        editor.syncModel();
        editor.removeUndoManagerFromDocument();
    }

In previous renditions, it was necessary for a TopComponent in a multiview that was acting as an editor for the XAM model directly (e.g. the Schema view of the XSD editor) to perform similar undoable edit listener management as shown above. Now, however, that is no longer the recommended strategy. Instead, the undo manager should be listening to the model all of the time, except when the source view is showing. This is managed by the code in the sample shown above.

Implementation Details

Following the advice of Jaroslav Tulach, with respect to issue 80586, the undo/redo solution employs a single undo/redo manager (the CloneableEditorSupport.CESUndoRedoManager defined in the openide/text module) for both document edits and model edits. However, this alone is not enough, as the following problems will occur.

  • When edits are undone, they generate additional edits, and subsequently they are added to the undo/redo queue. For instance, if you delete an element in the model view, then undo the change in the source view, the queue will now have a new document edit that represents the addition of the element text. This, in effect, reverses the edit that was just undone.
  • Document edits are undone individually, even when in the model view, and this may seem confusing, and will likely put the model in an invalid state.
  • Undoing document edits from the model view will create additional model edits, which are subsequently added to the queue and eventually lead to exceptions when undo/redo are invoked. This occurs despite managing the set of document undoable edit listeners when performing the undo or redo operation.

Several possible designs were considered and after much discussion and several days of attempting to implement the solution, the following strategy was developed.

  • The undo manager is a proxy that encapsulates the undo manager provided by CloneableEditorSupport. The code for this proxy undo manager lives in the xml/xamui module, in the org.netbeans.modules.xml.xam.ui.undo package.
  • Each multiview element adds the undo manager as an undoable edit listener to the edit origin when the view is shown (in componentShowing() method). Likewise, each multiview element removes the undo manager as an undoable edit listener from the edit origin when the view is hidden (in componentHidden() method).
  • The editor support subclass wraps the original undo manager with a proxy, which removes and then adds undoable edit listeners when undo/redo are performed. This avoids the problem of new edits being added to the queue when undo/redo is invoked. This must be done in the proxy undo manager since the original undo manager performs the undo/redo as an atomic edit on the document, which fires undoable edit events. Managing the listeners at a lower level will be ineffective.
  • The proxy undo manager syncs the Model after performing an undo/redo operation. This is used by model view to keep the model clean when undoing document edits, and avoids superfluous edits from appearing on the queue and causing errors. While the model may be calling unsetDirty() in its undo/redo operations, it is happening before the document events are fired, thus it has no impact.
  • The proxy undo manager supports the notion of a compound set of edits, such that they are undone/redone as a set, when not in compound mode. This is used by the source view to indicate that source edits are to be treated as a compound edit. When in the model view, the set of edits are operated on as if they were a single compound edit.
  • The sentinel edits (BeginCompoundEdit and EndCompoundEdit) are lazily added to the undo queue. This is to avoid wiping out the edits that have been undone. If the user creates an edit, then those undone edits are trimmed by the undo manager, at which point it is okay for the proxy to add the sentinel edits.
  • The proxy undo manager needs to perform the "compound" undo and redo since it is not feasible for the undoable edits to call on the undo manager to undo and redo edits. Subsequently, the sentinel edits do nothing more than set flags to indicate that a compound edit has been reached, and subsequently completed.

Other Suggested Solutions

The following ideas were put forth and eventually rejected as they had one flaw or another.

  • Ignore all model edits and only undo/redo the document edits. Initially this seems okay since when a model edit is performed, it modifies the document, which generates undoable edits. However, this does not make use of the fast undo/redo support in the model, and forces the model to sync with the document every time an undo or redo is performed. In addition, mutations of a component will result in two undoable edits (one removing the text, the second adding the modified text). When an edit is undone in the source view, it takes three seconds for the model to sync.
  • Translate edits from one format to the other (document edits to model edits, and vice versa) when switching between the source and model views. This was quickly determined to be too much work to be performed when switching views, as there could be up to 1000 edits on the undo queue.
  • Keeping the original two-undo-manager solution but making the model edits available on the undo queue when in the source view. This may have certainly worked, but the two-manager solution had other problems, which this did not address.

Project Features

About this Project

XML was started in November 2009, is owned by dstrupl, and has 58 members.
By use of this website, you agree to the NetBeans Policies and Terms of Use (revision 20160708.bf2ac18). © 2014, Oracle Corporation and/or its affiliates. Sponsored by Oracle logo
 
 
Close
loading
Please Confirm
Close