// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed 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 com.google.collide.client.workspace; import com.google.collide.client.bootstrap.BootstrapSession; import com.google.collide.client.ui.tree.TreeNodeElement; import com.google.collide.client.util.PathUtil; import com.google.collide.client.util.logging.Log; import com.google.collide.client.workspace.FileTreeModelNetworkController.OutgoingController; import com.google.collide.dto.DirInfo; import com.google.collide.dto.Mutation; import com.google.collide.dto.ServerError.FailureReason; import com.google.collide.dto.WorkspaceTreeUpdate; import com.google.collide.dto.client.DtoClientImpls.DirInfoImpl; import com.google.collide.dto.client.DtoClientImpls.WorkspaceTreeUpdateImpl; import com.google.collide.json.client.JsoArray; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.util.JsonCollections; import com.google.collide.shared.util.StringUtils; import com.google.common.base.Preconditions; import javax.annotation.Nullable; /** * Public API for interacting with the client side workspace file tree model. * Also exposes callbacks for mutations that have been applied to the model. * * If you want to mutate the workspace file tree, which is a tree of * {@link FileTreeNode}'s you need to go through here. */ public class FileTreeModel { /** * Callback interface for requesting the root node, potentially * asynchronously. */ public interface RootNodeRequestCallback { void onRootNodeAvailable(FileTreeNode root); } /** * Callback interface for requesting a node, potentially asynchronously. */ public interface NodeRequestCallback { void onNodeAvailable(FileTreeNode node); /** * Called if the node does not exist. */ void onNodeUnavailable(); /** * Called if an error occurs while loading the node. */ void onError(FailureReason reason); } /** * Callback interface for getting notified about changes to the workspace tree * model that have been applied by the FileTreeController. */ public interface TreeModelChangeListener { /** * Notification that a node was added. */ void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode); /** * Notification that a node was moved/renamed. * * @param oldPath the old node path * @param node the node that was moved, or null if the old path is not loaded. If both the old * path and the new path are loaded, node == newNode and node's parent will be the target * directory of the new path. If the new path is not loaded, node is the node that was in * the old path. * @param newPath the new node path * @param newNode the new node, or null if the target directory is not loaded */ void onNodeMoved(PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode); /** * Notification that a set of nodes was removed. * * @param oldNodes a list of nodes that we removed. Every node will still have its parent filled */ void onNodesRemoved(JsonArray<FileTreeNode> oldNodes); /** * Notification that a node was replaced (can be either a file or directory). * * @param oldNode the existing node that used to be in the file tree, or null if the workspace * root is being set for the first time * @param newNode the node that replaces the {@code oldNode}. This will be the same * {@link FileTreeNode#getNodeType()} as the node it is replacing. */ void onNodeReplaced(@Nullable FileTreeNode oldNode, FileTreeNode newNode); } /** * A {@link TreeModelChangeListener} which does not perform any operations in * response to an event. Its only purpose is to allow clients to only override * the events matter to them. */ public abstract static class AbstractTreeModelChangeListener implements TreeModelChangeListener { @Override public void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode) { // intentional no-op, clients should override if needed } @Override public void onNodeMoved( PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode) { // intentional no-op, clients should override if needed } @Override public void onNodesRemoved(JsonArray<FileTreeNode> oldNodes) { // intentional no-op, clients should override if needed } @Override public void onNodeReplaced(FileTreeNode oldDir, FileTreeNode newDir) { // intentional no-op, clients should override if needed } } /** * A {@link TreeModelChangeListener} that performs the exact same action in * response to any and all tree mutations. */ public abstract static class BasicTreeModelChangeListener implements TreeModelChangeListener { public abstract void onTreeModelChange(); @Override public void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode) { onTreeModelChange(); } @Override public void onNodeMoved( PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode) { onTreeModelChange(); } @Override public void onNodesRemoved(JsonArray<FileTreeNode> oldNodes) { onTreeModelChange(); } @Override public void onNodeReplaced(FileTreeNode oldDir, FileTreeNode newDir) { onTreeModelChange(); } } private interface ChangeDispatcher { void dispatch(TreeModelChangeListener changeListener); } private final JsoArray<TreeModelChangeListener> modelChangeListeners = JsoArray.create(); private final OutgoingController outgoingNetworkController; private FileTreeNode workspaceRoot; private boolean disableChangeNotifications; /** * Tree revision that corresponds to the revision of the last * successfully applied tree mutation that this client is aware of. */ private String lastAppliedTreeMutationRevision = "0"; public FileTreeModel( FileTreeModelNetworkController.OutgoingController outgoingNetworkController) { this.outgoingNetworkController = outgoingNetworkController; } /** * Adds a node to our model by path. */ public void addNode(PathUtil path, final FileTreeNode newNode, String workspaceRootId) { if (workspaceRoot == null) { // TODO: queue up this add? Log.warn(getClass(), "Attempting to add a node before the root is set", path); return; } // Find the parent directory of the node. final PathUtil parentDirPath = PathUtil.createExcludingLastN(path, 1); FileTreeNode parentDir = getWorkspaceRoot().findChildNode(parentDirPath); if (parentDir != null && parentDir.isComplete()) { // The parent directory is complete, so add the node. addNode(parentDir, newNode, workspaceRootId); } else { // The parent directory isn't complete, so do not add the node to the model, but update the // workspace root id. maybeSetLastAppliedTreeMutationRevision(workspaceRootId); } } /** * Adds a node to the model under the specified parent node. */ public void addNode(FileTreeNode parentDir, FileTreeNode childNode, String workspaceRootId) { addNodeNoDispatch(parentDir, childNode); dispatchAddNode(parentDir, childNode, workspaceRootId); } private void addNodeNoDispatch(final FileTreeNode parentDir, final FileTreeNode childNode) { if (parentDir == null) { Log.error(getClass(), "Trying to add a child to a null parent!", childNode); return; } Log.debug(getClass(), "Adding ", childNode, " - to - ", parentDir); parentDir.addChild(childNode); } /** * Manually dispatch that a node was added. */ void dispatchAddNode( final FileTreeNode parentDir, final FileTreeNode childNode, final String workspaceRootId) { dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodeAdded(parentDir.getNodePath(), childNode); } }, workspaceRootId); } /** * Moves/renames a node in the model. */ public void moveNode( final PathUtil oldPath, final PathUtil newPath, final String workspaceRootId) { if (workspaceRoot == null) { // TODO: queue up this move? Log.warn(getClass(), "Attempting to move a node before the root is set", oldPath); return; } // Remove the node from its old path if the old directory is complete. final FileTreeNode oldNode = workspaceRoot.findChildNode(oldPath); if (oldNode == null) { /* * No node found at the old path - either it isn't loaded, or we optimistically updated * already. Verify that one of those is the case. */ Preconditions.checkState(workspaceRoot.findClosestChildNode(oldPath) != null || workspaceRoot.findChildNode(newPath) != null); } else { oldNode.setName(newPath.getBaseName()); oldNode.getParent().removeChild(oldNode); } // Apply the new root id. maybeSetLastAppliedTreeMutationRevision(workspaceRootId); // Prepare a callback that will dispatch the onNodeMove event to listeners. NodeRequestCallback callback = new NodeRequestCallback() { @Override public void onNodeAvailable(FileTreeNode newNode) { /* * If we had to request the target directory, replace the target node with the oldNode to * ensure that all properties (such as the rendered node and the fileEditSessionKey) are * copied over correctly. */ if (oldNode != null && newNode != null && newNode != oldNode) { newNode.replaceWith(oldNode); newNode = oldNode; } // Dispatch a change event. final FileTreeNode finalNewNode = newNode; dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodeMoved(oldPath, oldNode, newPath, finalNewNode); } }, workspaceRootId); } @Override public void onNodeUnavailable() { // The node should be available because we are requesting the node using the root ID // immediately after the move. Log.error(getClass(), "Could not find moved node using the workspace root ID immediately after the move"); } @Override public void onError(FailureReason reason) { // Error already logged. } }; // Request the target directory. final PathUtil parentDirPath = PathUtil.createExcludingLastN(newPath, 1); FileTreeNode parentDir = workspaceRoot.findChildNode(parentDirPath); if (parentDir == null || !parentDir.isComplete()) { if (oldNode == null) { // Early exit if neither the old node nor the target directory is loaded. return; } else { // If the parent directory was not loaded, don't bother loading it. callback.onNodeAvailable(null); } } else { if (oldNode == null) { // The old node doesn't exist, so we need to force a refresh of the target directory's // children by marking the target directory incomplete. DirInfoImpl parentDirView = parentDir.cast(); parentDirView.setIsComplete(false); } else { // The old node exists and the target directory is loaded, so add the node to the target. parentDir.addChild(oldNode); } // Request the new node. requestWorkspaceNode(newPath, callback); } } /** * Removes a node from the model. * * @param toDelete the {@link FileTreeNode} we want to remove. * @param workspaceRootId the new file tree revision * @return the node that was deleted from the model. This will return * {@code null} if the input node is null or if the input node does * not have a parent. Meaning if the input node is the root, this * method will return {@code null}. */ public FileTreeNode removeNode(final FileTreeNode toDelete, String workspaceRootId) { // If we found a node at the specified path, then remove it. if (deleteNodeNoDispatch(toDelete)) { final JsonArray<FileTreeNode> deletedNode = JsonCollections.createArray(toDelete); dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodesRemoved(deletedNode); } }, workspaceRootId); return toDelete; } return null; } /** * Removes a set of nodes from the model. * * @param toDelete the {@link PathUtil}s for the nodes we want to remove. * @param workspaceRootId the new file tree revision * @return the nodes that were deleted from the model. This will return an * empty list if we try to add a node before we have a root node set, * or if the specified path does not exist.. */ public JsonArray<FileTreeNode> removeNodes( final JsonArray<PathUtil> toDelete, String workspaceRootId) { if (workspaceRoot == null) { // TODO: queue up this remove? Log.warn(getClass(), "Attempting to remove nodes before the root is set"); return null; } final JsonArray<FileTreeNode> deletedNodes = JsonCollections.createArray(); for (int i = 0; i < toDelete.size(); i++) { FileTreeNode node = workspaceRoot.findChildNode(toDelete.get(i)); if (deleteNodeNoDispatch(node)) { deletedNodes.add(node); } } if (deletedNodes.size() == 0) { // if none of the nodes created a need to update the UI, just return an // empty list. return deletedNodes; } dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodesRemoved(deletedNodes); } }, workspaceRootId); return deletedNodes; } /** * Deletes a single node (does not update the UI). */ private boolean deleteNodeNoDispatch(FileTreeNode node) { if (node == null || node.getParent() == null) { return false; } FileTreeNode parent = node.getParent(); // Guard against someone installing a node of the same name in the parent // (meaning we are already gone. if (!node.equals(parent.getChildNode(node.getName()))) { // This means that the node we are removing from the tree is already // effectively removed from where it thinks it is. return false; } node.getParent().removeChild(node); return true; } /** * Replaces either the root node for this tree model, or replaces an existing directory node, or * replaces an existing file node. */ public void replaceNode(PathUtil path, final FileTreeNode newNode, String workspaceRootId) { if (newNode == null) { return; } if (PathUtil.WORKSPACE_ROOT.equals(path)) { // Install the workspace root. final FileTreeNode oldDir = workspaceRoot; workspaceRoot = newNode; dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodeReplaced(oldDir, newNode); } }, workspaceRootId); } else { // Patch the model if there is one. if (workspaceRoot != null) { final FileTreeNode nodeToReplace = workspaceRoot.findChildNode(path); // Note. We do not support patching subtrees that don't already // exist. This subtree must have already existed, or have been // preceded by an ADD or COPY mutation. if (nodeToReplace == null) { return; } nodeToReplace.replaceWith(newNode); dispatchModelChange(new ChangeDispatcher() { @Override public void dispatch(TreeModelChangeListener changeListener) { changeListener.onNodeReplaced(nodeToReplace, newNode); } }, workspaceRootId); } } } /** * @return the current value of the workspaceRoot. Potentially {@code null} if * the model has not yet been populated. */ public FileTreeNode getWorkspaceRoot() { return workspaceRoot; } /** * Asks for the root node, potentially asynchronously if the model is not yet * populated. If the root node is already available then the callback will be * invoked synchronously. */ public void requestWorkspaceRoot(final RootNodeRequestCallback callback) { FileTreeNode rootNode = getWorkspaceRoot(); if (rootNode == null) { // Wait for the model to be populated. addModelChangeListener(new AbstractTreeModelChangeListener() { @Override public void onNodeReplaced(FileTreeNode oldNode, FileTreeNode newNode) { Preconditions.checkArgument(newNode.getNodePath().equals(PathUtil.WORKSPACE_ROOT), "Unexpected non-workspace root subtree replaced before workspace root was replaced: " + newNode.toString()); // Should be resilient to concurrent modification! removeModelChangeListener(this); callback.onRootNodeAvailable(getWorkspaceRoot()); } }); return; } callback.onRootNodeAvailable(rootNode); } /** * Adds a {@link TreeModelChangeListener} to be notified of mutations applied * by the FileTreeController to the underlying workspace file tree model. * * @param modelChangeListener the listener we are adding */ public void addModelChangeListener(TreeModelChangeListener modelChangeListener) { modelChangeListeners.add(modelChangeListener); } /** * Removes a {@link TreeModelChangeListener} from the set of listeners * subscribed to model changes. */ public void removeModelChangeListener(TreeModelChangeListener modelChangeListener) { modelChangeListeners.remove(modelChangeListener); } public void setDisableChangeNotifications(boolean disable) { this.disableChangeNotifications = disable; } private void dispatchModelChange(ChangeDispatcher dispatcher, String workspaceRootId) { // Update the tracked tip ID. maybeSetLastAppliedTreeMutationRevision(workspaceRootId); if (disableChangeNotifications) { return; } JsoArray<TreeModelChangeListener> copy = modelChangeListeners.slice( 0, modelChangeListeners.size()); for (int i = 0, n = copy.size(); i < n; i++) { dispatcher.dispatch(copy.get(i)); } } /** * @return the file tree revision associated with the last seen Tree mutation. */ public String getLastAppliedTreeMutationRevision() { return lastAppliedTreeMutationRevision; } /** * Bumps the tracked Root ID for the last applied tree mutation, if the * version happens to be larger than the version we are tracking. */ public void maybeSetLastAppliedTreeMutationRevision(String lastAppliedTreeMutationRevision) { // TODO: Ensure numeric comparison survives ID obfuscation. try { long newRevision = StringUtils.toLong(lastAppliedTreeMutationRevision); long lastRevision = StringUtils.toLong(this.lastAppliedTreeMutationRevision); this.lastAppliedTreeMutationRevision = (newRevision > lastRevision) ? lastAppliedTreeMutationRevision : this.lastAppliedTreeMutationRevision; // TODO: this should be monotonically increasing; if it's not, we missed an update. } catch (NumberFormatException e) { Log.error(getClass(), "Root ID is not a numeric long!", lastAppliedTreeMutationRevision); } } /** * Folks that want to mutate the file tree should obtain a skeletal {@link WorkspaceTreeUpdate} * using this factory method. */ public WorkspaceTreeUpdateImpl makeEmptyTreeUpdate() { if (this.lastAppliedTreeMutationRevision == null) { throw new IllegalStateException( "Attempted to mutate the tree before the workspace file tree was loaded at least once!"); } return WorkspaceTreeUpdateImpl.make() .setAuthorClientId(BootstrapSession.getBootstrapSession().getActiveClientId()) .setMutations(JsoArray.<Mutation>create()); } /** * Calculates the list of expanded paths. The list only contains the paths of the deepest expanded * directories. Parent directories are assumed to be open as well. * * @return the list of expanded paths, or null if the workspace root is not loaded */ public JsoArray<String> calculateExpandedPaths() { // Early exit if the root isn't loaded yet. if (workspaceRoot == null) { return null; } // Walk the tree looking for expanded paths. JsoArray<String> expandedPaths = JsoArray.create(); calculateExpandedPathsRecursive(workspaceRoot, expandedPaths); return expandedPaths; } /** * Calculates the list of expanded paths beneath the specified node and adds them to expanded * path. If none of the children * * @param node the directory containing the expanded paths * @param expandedPaths the running list of expanded paths */ private void calculateExpandedPathsRecursive(FileTreeNode node, JsoArray<String> expandedPaths) { assert node.isDirectory() : "node must be a directory"; // Check if the directory is expanded. The root is always expanded. if (node != workspaceRoot) { TreeNodeElement<FileTreeNode> dirElem = node.getRenderedTreeNode(); if (!dirElem.isOpen()) { return; } } // Recursively search for expanded subdirectories. int expandedPathsCount = expandedPaths.size(); DirInfoImpl dir = node.cast(); JsonArray<DirInfo> subDirs = dir.getSubDirectories(); if (subDirs != null) { for (int i = 0; i < subDirs.size(); i++) { DirInfo subDir = subDirs.get(i); calculateExpandedPathsRecursive((FileTreeNode) subDir, expandedPaths); } } // Add this directory if none of its descendants were added. if (expandedPathsCount == expandedPaths.size()) { expandedPaths.add(node.getNodePath().getPathString()); } } /** * Asks for the node at the specified path, potentially asynchronously if the model does not yet * contain the node. If the node is already available then the callback will be invoked * synchronously. * * @param path the path to the node, which must be a file (not a directory) * @param callback the callback to invoke when the node is ready */ public void requestWorkspaceNode(final PathUtil path, final NodeRequestCallback callback) { outgoingNetworkController.requestWorkspaceNode(this, path, callback); } /** * Asks for the children of the specified node. * * @param node a directory node * @param callback an optional callback that will be notified once the children are fetched. If * null, this method will alert the user if there was an error */ public void requestDirectoryChildren(FileTreeNode node, @Nullable final NodeRequestCallback callback) { outgoingNetworkController.requestDirectoryChildren(this, node, callback); } }