/*
* Sun Public License Notice
*
* The contents of this file are subject to the Sun Public License
* Version 1.0 (the "License"). You may not use this file except in
* compliance with the License. A copy of the License is available at
* http://www.sun.com/
*
* The Original Code is NetBeans. The Initial Developer of the Original
* Code is Sun Microsystems, Inc. Portions Copyright 1997-2002 Sun
* Microsystems, Inc. All Rights Reserved.
*/
package org.openide.explorer;
import java.awt.Component;
import java.beans.*;
import java.io.*;
import java.util.*;
import java.text.MessageFormat;
import javax.swing.SwingUtilities;
import org.openide.nodes.*;
import org.openide.ErrorManager;
import org.openide.util.*;
import org.openide.util.datatransfer.*;
import org.openide.util.io.SafeException;
/**
* Manages a selection and root context for a (set of) Explorer view(s). The
* views should register their {@link java.beans.VetoableChangeListener}s and
* {@link java.beans.PropertyChangeListener}s at the
* <code>ExplorerManager</code> of the Explorer they belong to. The manager
* listens on changes to the node hierarchy and updates the selection and root
* node.
*
* <P>Deserialization may throw {@link SafeException} if the contexts cannot be
* restored correctly, but the stream is uncorrupted.
*
* @author Ian Formanek, Petr Hamernik, Jaroslav Tulach, Jan Jancura,
* Jesse Glick
*/
public final class ExplorerManager extends Object
implements Serializable, Cloneable {
/** generated Serialized Version UID */
static final long serialVersionUID = -4330330689803575792L;
/** Name of property for the root context. */
public static final String PROP_ROOT_CONTEXT = "rootContext"; // NOI18N
/** Name of property for the explored context. */
public static final String PROP_EXPLORED_CONTEXT = "exploredContext"; // NOI18N
/** Name of property for the node selection. */
public static final String PROP_SELECTED_NODES = "selectedNodes"; // NOI18N
/** Name of property for change in a node. */
public static final String PROP_NODE_CHANGE = "nodeChange"; // NOI18N
/** The support for VetoableChangeEvent */
private transient VetoableChangeSupport vetoableSupport;
/** The support for PropertyChangeEvent */
private transient PropertyChangeSupport propertySupport;
/** The current root context */
private Node rootContext;
/** The current explored context */
private Node exploredContext;
/** The currently selected beans */
private Node[] selectedNodes;
/** listener to destroy of root node */
private transient Listener listener;
/** weak listener */
private transient NodeListener weakListener;
/** Request processor for managing selections.
*/
static RequestProcessor selectionProcessor;
/** Delay for coalescing events before removing destroyed nodes from
the selection.
*/
private static final int SELECTION_SYNC_DELAY = 200;
/** Task that removes manages node selection issues.
*/
private RequestProcessor.Task selectionSyncTask;
// this static loading of CallbackSystemAction is here because
// when we are in standalone library we need the CallbackSystemAction
// be loaded (its static initializers performed) in order
// to any ExplorerManager to work.
static {
try {
Class.forName("org.openide.util.actions.CallbackSystemAction"); // NOI18N
} catch (Exception y) {
}
}
/** Construct a new manager. */
public ExplorerManager () {
init ();
}
/** Initializes the nodes.
*/
private void init () {
exploredContext = rootContext = Node.EMPTY;
selectedNodes = new Node[0];
listener = new Listener ();
weakListener = WeakListener.node (listener, null);
}
/** Clones the manager.
* @return manager with the same settings like this one
*/
public Object clone () {
ExplorerManager em = new ExplorerManager ();
em.rootContext = rootContext;
em.exploredContext = exploredContext;
em.selectedNodes = selectedNodes;
return em;
}
/** Get the set of selected nodes.
* @return the selected nodes; empty (not <code>null</code>) if none are selected
*/
public Node[] getSelectedNodes () {
return selectedNodes;
}
/** Set the set of selected nodes.
* @param value the nodes to select; empty (not <code>null</code>) if none are to be selected
* @exception PropertyVetoException when the given nodes cannot be selected
* @throws IllegalArgumentException if <code>null</code> is given, or if any elements
* of the selection are not within the current root context
*/
public final void setSelectedNodes (Node[] value) throws PropertyVetoException {
Node[] oldValue;
synchronized (this) {
if (value == null) throw new IllegalArgumentException(getString("EXC_NodeCannotBeNull"));
if (Arrays.equals (value, selectedNodes)) {
return;
}
for (int i = 0; i < value.length; i++) {
if (value[i] == null) {
throw new IllegalArgumentException(getString("EXC_NoElementOfNodeSelectionMayBeNull"));
}
if (! isUnderRoot (value[i])) {
throw new IllegalArgumentException(
MessageFormat.format(
getString("EXC_NodeSelectionCannotContainNodes"),
new Object[] {
value[i].getDisplayName(),
rootContext.getDisplayName(),
}
)
);
}
}
if (value.length != 0 && vetoableSupport != null) {
// we send the vetoable change event only for non-empty selections
vetoableSupport.fireVetoableChange(PROP_SELECTED_NODES, selectedNodes, value);
}
oldValue = selectedNodes;
Collection nodesToRemove;
Collection nodesToAdd;
Collection newSelection = new LinkedList();
Collection currentNodes = Arrays.asList(oldValue);
for (int i = 0; i < value.length; i++) {
newSelection.add(value[i]);
}
nodesToAdd = new LinkedList(newSelection);
nodesToAdd.removeAll(currentNodes);
nodesToRemove = new LinkedList(currentNodes);
nodesToRemove.removeAll(newSelection);
// PENDING: filter out duplicities from the selection
if (newSelection.size() == value.length) {
selectedNodes = value;
} else {
selectedNodes = new Node[value.length];
newSelection.toArray(selectedNodes);
}
Iterator it;
// remove listeners from nodes that are being deselected
for (it = nodesToRemove.iterator(); it.hasNext(); ) {
Node n = (Node)it.next();
n.removeNodeListener(weakListener);
}
// and add listeners to nodes that become selected
for (it = nodesToAdd.iterator(); it.hasNext(); ) {
Node n = (Node)it.next();
n.removeNodeListener(weakListener);
n.addNodeListener(weakListener);
}
} // synchronized (this)
if (propertySupport != null) {
// replan fire of prop event to AWT-queue to get correctly visual-reactions
if (SwingUtilities.isEventDispatchThread ()) {
propertySupport.firePropertyChange (PROP_SELECTED_NODES, oldValue, selectedNodes);
} else {
final Node[] tempOldValue = oldValue;
SwingUtilities.invokeLater(new Runnable () {
public void run () {
propertySupport.firePropertyChange (PROP_SELECTED_NODES, tempOldValue, selectedNodes);
}
});
}
}
}
/** Get the explored context.
* <p>The "explored context" is not as frequently used as the node selection;
* generally it refers to a parent node which contains all of the things
* being displayed at this moment. For <code>BeanTreeView</code> this is
* irrelevant, but <code>ContextTreeView</code> uses it (in lieu of the node
* selection) and for <code>IconView</code> it is important (the node
* whose children are visible, i.e. the "background" of the icon view).
* @return the node being explored, or <code>null</code>
*/
public final Node getExploredContext() {
return exploredContext;
}
/** Set the explored context.
* The node selection will be cleared as well.
* @param value the new node to explore, or <code>null</code> if none should be explored.
* @throws IllegalArgumentException if the node is not within the current root context in the node hierarchy
*/
public final void setExploredContext(Node value) {
setExploredContext(value, new Node[0]);
}
/** Set the explored context.
* The node selection will be changed as well. Note: node selection cannot be
* vetoed if calling this method. It is generally better to call setExploredContextAndSelection.
* @param value the new node to explore, or <code>null</code> if none should be explored.
* @throws IllegalArgumentException if the node is not within the current root context in the node hierarchy
*/
public final void setExploredContext(Node value, Node[] selection) {
// handles nulls correctly:
if (Utilities.compareObjects (value, exploredContext)) {
setSelectedNodes0(selection);
return;
}
if (value != null && ! isUnderRoot (value)) {
throw new IllegalArgumentException(
MessageFormat.format(
getString("EXC_ContextMustBeWithinRootContext"),
new Object[] {
value.getDisplayName (),
rootContext.getDisplayName ()
}
)
);
}
setSelectedNodes0(selection);
final Node oldValue = exploredContext;
exploredContext = value;
if (propertySupport != null) {
// replan fire of prop event to AWT-queue to get correctly visual-reactions
if (SwingUtilities.isEventDispatchThread ()) {
propertySupport.firePropertyChange (PROP_EXPLORED_CONTEXT, oldValue, exploredContext);
} else {
SwingUtilities.invokeLater(new Runnable () {
public void run () {
propertySupport.firePropertyChange (PROP_EXPLORED_CONTEXT, oldValue, exploredContext);
}
});
}
}
}
/** Set the explored context and selected nodes. If the change in selected nodes is vetoed,
* PropertyVetoException is rethrown from here.
* @param value the new node to explore, or <code>null</code> if none should be explored.
* @param selection the new nodes to be selected
* @throws IllegalArgumentException if the node is not within the current root context in the node hierarchy
* @throws PropertyVetoExcepion if listeners attached to this explorer manager do so
*/
public final void setExploredContextAndSelection(Node value, Node[] selection) throws PropertyVetoException {
// handles nulls correctly:
if (Utilities.compareObjects (value, exploredContext)) {
setSelectedNodes1(selection);
return;
}
if (value != null && ! isUnderRoot (value)) {
throw new IllegalArgumentException(
MessageFormat.format(
getString("EXC_ContextMustBeWithinRootContext"),
new Object[] {
value.getDisplayName (),
rootContext.getDisplayName ()
}
)
);
}
setSelectedNodes1(selection);
final Node oldValue = exploredContext;
exploredContext = value;
if (propertySupport != null) {
// replan fire of prop event to AWT-queue to get correctly visual-reactions
if (SwingUtilities.isEventDispatchThread ()) {
propertySupport.firePropertyChange (PROP_EXPLORED_CONTEXT, oldValue, exploredContext);
} else {
SwingUtilities.invokeLater(new Runnable () {
public void run () {
propertySupport.firePropertyChange (PROP_EXPLORED_CONTEXT, oldValue, exploredContext);
}
});
}
}
}
/** Sets selected nodes and handles PropertyVetoException */
final void setSelectedNodes0(Node[] nodes) {
try {
setSelectedNodes(nodes);
} catch (PropertyVetoException e) {
if (nodes.length == 0) {
IllegalStateException err = new IllegalStateException(getString("EXC_MustNotVetoEmptySelection"));
ErrorManager.getDefault ().annotate(err, e);
// Should be impossible regardless of view:
throw err;
}
}
}
/** Sets selected nodes and handles PropertyVetoException */
private void setSelectedNodes1(Node[] nodes) throws PropertyVetoException {
try {
setSelectedNodes(nodes);
} catch (PropertyVetoException e) {
if (nodes.length == 0) {
IllegalStateException err = new IllegalStateException(getString("EXC_MustNotVetoEmptySelection"));
ErrorManager.getDefault ().annotate(err, e);
// Should be impossible regardless of view:
throw err;
}
throw e; // this is the difference to setSelectedNodes0
}
}
/** Get the root context.
* <p>The "root context" is simply the topmost node that this explorer can
* display or manipulate. For <code>BeanTreeView</code>, this would mean
* the root node of the tree. For e.g. <code>IconView</code>, this would
* mean the uppermost possible node that that icon view could display;
* while the explored context would change at user prompting via the
* up button and clicking on subfolders, the root context would be fixed
* by the code displaying the explorer.
* @return the root context node
*/
public final Node getRootContext() {
return rootContext;
}
/** Set the root context.
* The explored context will be set to the new root context as well.
* If any of the selected nodes are not inside it, the selection will be cleared.
* @param value the new node to serve as a root
* @throws IllegalArgumentException if it is <code>null</code>
*/
public final void setRootContext(Node value) {
if (value == null) throw new IllegalArgumentException(getString("EXC_CannotHaveNullRootContext"));
if (rootContext.equals (value)) return;
final Node oldValue = rootContext;
rootContext = value;
oldValue.removeNodeListener (weakListener);
rootContext.addNodeListener (weakListener);
if (propertySupport != null) {
// replan fire of prop event to AWT-queue to get correctly visual-reactions
if (SwingUtilities.isEventDispatchThread ()) {
propertySupport.firePropertyChange (PROP_ROOT_CONTEXT, oldValue, rootContext);
} else {
SwingUtilities.invokeLater(new Runnable () {
public void run () {
propertySupport.firePropertyChange (PROP_ROOT_CONTEXT, oldValue, rootContext);
}
});
}
}
Node[] newselection = getSelectedNodes();
if (!areUnderTarget(newselection, rootContext)) {
newselection = new Node[0];
}
setExploredContext(rootContext, newselection);
}
/** @return true iff all nodes are under the target node */
private boolean areUnderTarget(Node[] nodes, Node target) {
bigloop: for (int i = 0; i < nodes.length; i++) {
Node node = nodes[i];
while (node != null) {
if (node.equals(target)) {
continue bigloop;
}
node = node.getParentNode();
}
return false;
}
return true;
}
/** Add a <code>PropertyChangeListener</code> to the listener list.
* @param l the listener to add
*/
public synchronized void addPropertyChangeListener(PropertyChangeListener l) {
if (propertySupport == null)
propertySupport = new PropertyChangeSupport(this);
propertySupport.addPropertyChangeListener(l);
}
/** Remove a <code>PropertyChangeListener</code> from the listener list.
* @param l the listener to remove
*/
public synchronized void removePropertyChangeListener(PropertyChangeListener l) {
if (propertySupport != null)
propertySupport.removePropertyChangeListener(l);
}
/** Add a <code>VetoableListener</code> to the listener list.
* @param l the listener to add
*/
public synchronized void addVetoableChangeListener(VetoableChangeListener l) {
if (vetoableSupport == null)
vetoableSupport = new VetoableChangeSupport(this);
vetoableSupport.addVetoableChangeListener(l);
}
/** Remove a <code>VetoableChangeListener</code> from the listener list.
* @param l the listener to remove
*/
public synchronized void removeVetoableChangeListener(VetoableChangeListener l) {
if (vetoableSupport != null)
vetoableSupport.removeVetoableChangeListener(l);
}
/** Checks whether given Node is a subnode of rootContext.
* @return true if specified Node is under current rootContext
*/
private boolean isUnderRoot(Node node) {
while (node != null) {
if (node.equals(rootContext)) return true;
node = node.getParentNode();
}
return false;
}
/** defines serialized fields for the manager.
*/
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField ("root", Node.Handle.class), // NOI18N
new ObjectStreamField ("rootName", String.class), // NOI18N
new ObjectStreamField ("explored", String[].class), // NOI18N
// XXX(-ttran) this should be String[][].class, but cannot be changed
// now because of backward compatibility
new ObjectStreamField ("selected", Object[].class) // NOI18N
};
/** serializes object
* @serialData the following objects are written in sequence:
* <ol>
* <li> a Node.Handle for the root context; may be null if root context
* is not persistable
* <li> the display name of the root context (to give nicer error messages
* later on)
* <li> the path from root context to explored context; null if no explored
* context or no such path
* <li> for every element of node selection, path from root context to that node;
* null if no such path
* <li> null to terminate
* </ol>
* Note that if the root context handle is null, the display name is still written
* but the paths to explored context and node selection are not written, the stream
* ends there.
*/
private void writeObject (ObjectOutputStream os) throws IOException {
// indication that we gonna use put fields and not the old method.
os.writeObject (this);
ObjectOutputStream.PutField fields = os.putFields ();
// [PENDING] is this method (and readObject) always called from within
// the Nodes mutex? It should be!
//System.err.println("rootContext: " + rootContext);
Node.Handle rCH = rootContext.getHandle ();
fields.put ("root", rCH); // NOI18N
//System.err.println("writing: " + rCH);
fields.put ("rootName", rootContext.getDisplayName ()); // NOI18N
//System.err.println("writing: " + rootContext.getDisplayName ());
// If root cannot be stored, we just stop right there, of course.
if (rCH != null) {
// Note that explored context may be null (this is valid).
// Also, it may have happened that the hierarchy changed so that
// the explored context is *no longer* under the root (though it was at
// the time these things were set up). In this case, we cannot store the
// path. Caution: NodeOp.createPath will create a path to a root (parentless)
// node even if you specify a non-null root, if the first arg is not a child!
String[] explored;
if (exploredContext == null)
explored = null;
else if (isUnderRoot (exploredContext))
explored = NodeOp.createPath (exploredContext, rootContext);
else
explored = null;
fields.put ("explored", explored); // NOI18N
// Now do the same for each node selection, with the same caveats.
// Null terminates, so bad elements are simply skipped.
LinkedList selected = new LinkedList ();
for (int i = 0; i < selectedNodes.length; i++) {
if (isUnderRoot (selectedNodes[i])) {
selected.add (NodeOp.createPath (selectedNodes[i], rootContext));
}
}
fields.put ("selected", selected.toArray ()); // NOI18N
}
os.writeFields();
}
/** Deserializes the view and initializes it
* @serialData see writeObject
*/
private void readObject (ObjectInputStream ois) throws IOException, ClassNotFoundException {
// perform initialization
init();
// read the first object in the stream
Object firstObject = ois.readObject ();
if (firstObject != this) {
// use old version of deserialization
readObjectOld ((Node.Handle)firstObject, ois);
return;
}
// work with get fields
ObjectInputStream.GetField fields = ois.readFields();
// read root handle
Node.Handle h = (Node.Handle)fields.get ("root", null); // NOI18N
//System.err.println("reading: " + h);
final String rootName = (String)fields.get ("rootName", null); // NOI18N
//System.err.println("reading: " + rootName);
if (h == null) {
// Cancel deserialization (e.g. of the ExplorerPanel window) in case the
// root handle was not persistent:
throw new SafeException(
new IOException(NbBundle.getMessage(ExplorerManager.class, "EXC_cannot_deser_null_handle", rootName))
);
} else {
String[] exploredCtx = (String[]) fields.get ("explored", null); // NOI18N
Object[] selPaths = (Object[]) fields.get ("selected", null); // NOI18N
try {
Node root = h.getNode ();
if (root == null)
throw new IOException("Node.Handle.getNode (for " + rootName + ") should not return null"); // NOI18N
restoreSelection(root, exploredCtx, Arrays.asList(selPaths));
} catch (IOException ioe) {
if (! Utilities.compareObjects (ioe.getMessage (), ioe.getLocalizedMessage ())) {
// already localized
throw new SafeException (ioe);
} else {
throw new SafeException (ioe) {
public String getLocalizedMessage () {
return NbBundle.getMessage (ExplorerManager.class, "EXC_handle_failed", rootName);
}
};
}
}
}
}
private void readObjectOld (Node.Handle h, ObjectInputStream ois)
throws java.io.IOException, ClassNotFoundException
{
if (h == null) {
// do nothing => should not occur to often and moreover this is also
// dead code replaced by new version
return;
}
else {
String[] rootCtx = (String[]) ois.readObject();
String[] exploredCtx = (String[]) ois.readObject ();
LinkedList ll = new LinkedList ();
for (;;) {
String[] path = (String[]) ois.readObject();
if (path == null)
break;
ll.add(path);
}
Node root = findPath (h.getNode (), rootCtx);
restoreSelection(root, exploredCtx, ll);
}
}
private void restoreSelection(final Node root,
final String[] exploredCtx,
final List selectedPaths /* of String[] */) {
setRootContext(root);
// XXX(-ttran) findPath() can take a long time and employs DataSystems
// and others. We cannot call it synchrorously, in the past deadlocks
// have happened because of this. OTOH as we call setSelectedNodes
// asynchonously someone else can change the root context or the Node
// hierarchy in between, which causes setSelectedNodes to throw
// IllegalArgumentException. There seems to be no simple good
// solution. For now we just catch IllegalArgumentException and be
// decently silent about the fact.
RequestProcessor.getDefault().post(new Runnable() {
public void run() {
// convert paths to Nodes
List selNodes = new ArrayList(selectedPaths.size());
for (Iterator iter = selectedPaths.iterator(); iter.hasNext(); ) {
String[] path = (String[]) iter.next();
selNodes.add(findPath(root, path));
}
// set the selection
try {
Node[] newSelection = (Node[]) selNodes.toArray(new Node[selNodes.size ()]);
if (exploredCtx != null) {
setExploredContext(findPath(root, exploredCtx), newSelection);
}
else {
setSelectedNodes0(newSelection);
}
}
catch (IllegalArgumentException ignore) {}
}
});
}
/**
* Finds the proper Explorer manager for a given component. This is done
* by traversing the component hierarchy and finding the first ancestor
* that implements {@link Provider}. <P> This method should be used in
* {@link Component#addNotify} of each component that works with the
* Explorer manager, e.g.:
* <p><pre>
* private transient ExplorerManager explorer;
*
* public void addNotify () {
* super.addNotify ();
* explorer = ExplorerManager.find (this);
* }
* </pre>
*
* @param comp component to find the manager for
* @return the manager, or a new empty manager if no ancestor implements
* <code>Provider</code>
*
* @see Provider
*/
public static ExplorerManager find (Component comp) {
// start looking for manager from parent, not the component itself
for (;;) {
comp = comp.getParent ();
if (comp == null) {
// create new explorer because nothing has been found
return new ExplorerManager ();
}
if (comp instanceof Provider) {
// ok, found a provider, return its manager
return ((Provider)comp).getExplorerManager ();
}
}
}
/** Finds node by given path */
static Node findPath(Node r, String[] path) {
try {
return NodeOp.findPath(r, path);
} catch (NodeNotFoundException ex) {
return ex.getClosestNode();
}
}
/** Creates or retrieves RequestProcessor for selection updates. */
static synchronized RequestProcessor getSelectionProcessor() {
if (selectionProcessor == null) {
selectionProcessor = new RequestProcessor("ExplorerManager-selection"); //NOI18N
}
return selectionProcessor;
}
//
// inner classes
//
/** Interface for components wishing to provide their own <code>ExplorerManager</code>.
* @see ExplorerManager#find
*/
public static interface Provider {
/** Get the explorer manager.
* @return the manager
*/
public ExplorerManager getExplorerManager ();
}
/** Listener to be notified when root node has been destroyed.
* Then the root node is changed to Node.EMPTY
*/
private class Listener extends NodeAdapter implements Runnable {
Collection removeList = new HashSet();
Listener() {}
/** Fired when the node is deleted.
* @param ev event describing the node
*/
public void nodeDestroyed(NodeEvent ev) {
if (ev.getNode ().equals (getRootContext ())) {
// node has been deleted
// [PENDING] better to show a node with a label such as "<deleted>"
// and a tool tip explaining the situation
setRootContext (Node.EMPTY);
} else {
// assume that the node is among currently selected nodes
scheduleRemove(ev.getNode());
}
}
/* Change in a node.
* @param ev the event
*/
public void propertyChange (java.beans.PropertyChangeEvent ev) {
if (propertySupport != null) {
// replan fire of prop event to AWT-queue to get correctly visual-reactions
if (SwingUtilities.isEventDispatchThread ()) {
propertySupport.firePropertyChange (PROP_NODE_CHANGE, null, null);
} else {
SwingUtilities.invokeLater(new Runnable () {
public void run () {
propertySupport.firePropertyChange (PROP_NODE_CHANGE, null, null);
}
});
}
}
}
/** Schedules removal of a node
*/
private void scheduleRemove(Node n) {
synchronized (ExplorerManager.this) {
if (selectionSyncTask == null) {
selectionSyncTask = getSelectionProcessor().create(this);
} else {
selectionSyncTask.cancel();
}
}
synchronized (this) {
removeList.add(n);
}
// invariant: selectionSyncTask != null && is not running yet.
selectionSyncTask.schedule(SELECTION_SYNC_DELAY);
}
public void run() {
Collection remove;
synchronized (this) {
// atomically clears the list while keeping a copy.
// if another node is removed after this point, the selection
// will be updated later.
remove = removeList;
removeList = new HashSet();
}
Collection newSel = new LinkedList(Arrays.asList(getSelectedNodes()));
newSel.removeAll(remove);
Node[] selNodes = (Node[]) newSel.toArray(new Node[newSel.size()]);
setSelectedNodes0(selNodes);
}
}
private static String getString(String key) {
return NbBundle.getMessage(ExplorerManager.class,key);
}
}